From 651ef6aec2b92c88ca64459b7a00ed5bb0edf070 Mon Sep 17 00:00:00 2001 From: moppeeer <339764117+moppeeer@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:53:51 +0200 Subject: [PATCH] feat(ui): Lay out the New Document window like InDesign's File > New > Document now shows Recent, Saved, Print, Web and Mobile tabs of page cards (drawn to the preset's proportions; double-click creates) beside the preset's details: document name, width and height with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns and gutter, and collapsible margins and bleed and slug per edge with link toggles. Save Document Preset keeps presets by name; documents made are remembered for the Recent tab. Small windows stack the details above the presets and scroll; the layout mirrors for right-to-left languages. New strings go through the translation tables (Italian, German, French, Spanish, Japanese, Chinese). file.new takes intent, units, startPage and per-edge bleed and slug. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/file.rs | 53 +- crates/engine/src/lib.rs | 5 + crates/ui-egui/src/dialogs.rs | 191 +++---- crates/ui-egui/src/i18n.rs | 58 ++ crates/ui-egui/src/i18n/it.rs | 19 + crates/ui-egui/src/icons.rs | 9 + crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/new_document.rs | 847 +++++++++++++++++++++++++++++ 9 files changed, 1062 insertions(+), 128 deletions(-) create mode 100644 crates/ui-egui/src/new_document.rs diff --git a/ROADMAP.md b/ROADMAP.md index 86a435456..1f2a61a91 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. +- New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates), and preset details beside them — document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset; `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. - 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. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f871a43e8..648e2a05e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -13,7 +13,7 @@ use crate::{DocState, EngineError, Result, Session}; pub fn specs() -> Vec { vec![ cmd!(noundo "file.new", "Document…", ["File", "New"], Some("Cmd+N"), - "{preset?: \"Letter\"|\"A4\"|…, width?, height?, pages?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, title?}", + "{preset?: \"Letter\"|\"A4\"|…, intent?: print|web|mobile, units?: points|picas|millimeters|…, width?, height?, pages?, startPage?, facingPages?, columns?, gutter?, margins?: number|{top,bottom,inside,outside}, bleed?, slug?: n | [top, bottom, inside, outside], title?}", always, file_new), cmd!(noundo "file.newSample", "Sample Document", ["Help"], None, "{} — a multi-page magazine sample", always, file_sample), cmd!(query "file.presets", "Document Presets", [], None, "{}", always, |_, _| Ok(serde_json::to_value(PRESETS).unwrap_or_default())), @@ -120,9 +120,16 @@ fn file_new(s: &mut Session, p: &Value) -> Result { Some(name) => NewDocument::from_preset(name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?, None => NewDocument::default(), }; + if let Some(intent) = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()) { + nd.intent = intent; + } + if let Some(units) = p.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()) { + nd.units = units; + } nd.width = f64_or(p, "width", nd.width); nd.height = f64_or(p, "height", nd.height); nd.pages = p.get("pages").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(nd.pages).clamp(1, 9999); + nd.start_page = p.get("startPage").and_then(Value::as_u64).map(|v| v.clamp(1, 99_999) as u32).unwrap_or(nd.start_page); nd.facing_pages = p.get("facingPages").and_then(Value::as_bool).unwrap_or(nd.facing_pages); nd.columns = p.get("columns").and_then(Value::as_u64).map(|v| v.clamp(1, 216) as u32).unwrap_or(nd.columns); nd.gutter = f64_or(p, "gutter", nd.gutter); @@ -136,8 +143,11 @@ fn file_new(s: &mut Session, p: &Value) -> Result { } _ => {} } - if let Some(b) = p.get("bleed").and_then(Value::as_f64) { - nd.bleed = [b; 4]; + if let Some(b) = edges(p, "bleed") { + nd.bleed = b; + } + if let Some(b) = edges(p, "slug") { + nd.slug = b; } s.untitled += 1; nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled)); @@ -149,6 +159,18 @@ fn file_new(s: &mut Session, p: &Value) -> Result { Ok(json!({"index": i})) } +/// A bleed or slug parameter: one width for every edge, or [top, bottom, inside, outside]. +fn edges(p: &Value, key: &str) -> Option<[f64; 4]> { + let clean = |v: f64| if v.is_finite() { v.clamp(0.0, 1296.0) } else { 0.0 }; + match p.get(key)? { + Value::Array(a) => match a.iter().map(Value::as_f64).collect::>>()?.as_slice() { + [t, b, i, o] => Some([clean(*t), clean(*b), clean(*i), clean(*o)]), + _ => None, + }, + v => v.as_f64().map(|b| [clean(b); 4]), + } +} + fn file_sample(s: &mut Session, _p: &Value) -> Result { let d = crate::sample::magazine(); let i = s.add_document(DocState::new(d, None)); @@ -663,6 +685,31 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { } } +#[cfg(test)] +mod new_document_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn new_document_takes_intent_units_start_page_bleed_and_slug() { + let mut s = Session::new(); + let p = json!({"preset": "A4", "intent": "web", "units": "pixels", "startPage": 4, "bleed": [9, 0, 3, 6], "slug": 18}); + s.execute("file.new", &p).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.intent, designcraft_doc::Intent::Web); + assert_eq!(d.settings.horizontal_units, designcraft_geom::Unit::Pixels); + assert_eq!(d.settings.bleed, [9.0, 0.0, 3.0, 6.0]); + assert_eq!(d.settings.slug, [18.0; 4]); + assert_eq!(d.page_name(0), "4"); + // Malformed edges are ignored, hostile ones bounded. + s.execute("file.new", &json!({"bleed": [1, 2], "slug": [-5, 1e12, 0, 0]})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.settings.bleed, [0.0; 4]); + assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]); + } +} + #[cfg(test)] mod pdf_crop_tests { use serde_json::json; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 771c89b13..dd9ee72ca 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -282,6 +282,11 @@ impl Default for Session { } impl Session { + /// The name `file.new` gives the next document without a title (New Document shows it). + pub fn next_untitled_title(&self) -> String { + format!("Untitled-{}", self.untitled.saturating_add(1)) + } + pub fn new() -> Self { guard::install_panic_hook(); Session { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2ab7a3bad..eb5e6120a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,10 +21,7 @@ impl Dialog { _ => Map::new(), }; let defaults = match id { - "newDocument" => { - json!({"preset": "Letter", "width": "51p0", "height": "66p0", "pages": 1, "facingPages": true, "columns": 1, "gutter": "1p0", - "marginTop": "3p0", "marginBottom": "3p0", "marginInside": "3p0", "marginOutside": "3p0", "bleed": "0p0", "primaryTextFrame": false}) - } + "newDocument" => crate::new_document::defaults(), "goToPage" => json!({"page": "1"}), "qrCode" => json!({"type": "url", "content": "https://", "color": "[Black]"}), "insertTable" => json!({"bodyRows": 4, "columns": 4, "headerRows": 0, "footerRows": 0}), @@ -77,7 +74,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 +96,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(), @@ -1161,7 +1158,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, - "newDocument" if crate::i18n::is_rtl(&app.ui.language) => 380.0, + "newDocument" => 960.0, _ => 640.0, }; let width = preferred_width.min(available.x); @@ -1184,79 +1181,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { 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| { match d.id.as_str() { "newDocument" => { - ui.horizontal(|ui| { - ui.with_layout(if crate::i18n::is_rtl(&app.ui.language) { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Preset")); - let cur = d.s("preset"); - egui::ComboBox::from_id_salt("preset").selected_text(&cur).width(180.0).show_ui(ui, |ui| { - for p in designcraft_doc::build::PRESETS { - if ui.selectable_label(cur == p.name, p.name).clicked() { - d.fields.insert("preset".into(), json!(p.name)); - d.fields.insert("width".into(), json!(format_measure(p.width, p.units))); - d.fields.insert("height".into(), json!(format_measure(p.height, p.units))); - d.fields.insert("facingPages".into(), json!(p.intent == designcraft_doc::Intent::Print)); - } - } - }); - }); - }); - if crate::i18n::is_rtl(&app.ui.language) { - // egui Grid currently supports LTR only. Use explicit RTL rows; - // the numeric editors retain LTR alignment and logical values. - for (first, key, second, other, toggle) in [ - ("Width", "width", "Height", "height", false), - ("Pages", "pages", "Facing Pages", "facingPages", true), - ("Columns", "columns", "Gutter", "gutter", false), - ("Top", "marginTop", "Bottom", "marginBottom", false), - ("Inside", "marginInside", "Outside", "marginOutside", false), - ("Bleed", "bleed", "Primary Text Frame", "primaryTextFrame", true), - ] { - ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, first)); - text_field(ui, &mut d, key, 80.0); - if toggle { - check(ui, &mut d, other, crate::i18n::tr(&app.ui.language, second)); - } else { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, second)); - text_field(ui, &mut d, other, 80.0); - } - }); - }); - } - } else { - egui::Grid::new("nd").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Width")); - text_field(ui, &mut d, "width", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Height")); - text_field(ui, &mut d, "height", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Pages")); - text_field(ui, &mut d, "pages", 80.0); - ui.label(""); - check(ui, &mut d, "facingPages", crate::i18n::tr(&app.ui.language, "Facing Pages")); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Columns")); - text_field(ui, &mut d, "columns", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Gutter")); - text_field(ui, &mut d, "gutter", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Top")); - text_field(ui, &mut d, "marginTop", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bottom")); - text_field(ui, &mut d, "marginBottom", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Inside")); - text_field(ui, &mut d, "marginInside", 80.0); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Outside")); - text_field(ui, &mut d, "marginOutside", 80.0); - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Bleed")); - text_field(ui, &mut d, "bleed", 80.0); - ui.label(""); - check(ui, &mut d, "primaryTextFrame", crate::i18n::tr(&app.ui.language, "Primary Text Frame")); - ui.end_row(); - }); + // A double-clicked preset creates the document at once. + let create = crate::new_document::body(app, ui, &mut d, width, body_height); + if create { + result = Some(true); } } "frameSize" => { @@ -1703,12 +1631,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } }); ui.add_space(12.0); - let ok_label = if d.id == "closeDocument" { " Save " } else { " OK " }; + let ok_label = match d.id.as_str() { + "closeDocument" => crate::i18n::tr(&app.ui.language, " Save ").to_string(), + "newDocument" => format!(" {} ", crate::i18n::tr(&app.ui.language, "Create")), + _ => crate::i18n::tr(&app.ui.language, " OK ").to_string(), + }; + let cancel_label = if d.id == "newDocument" { "Close" } else { "Cancel" }; ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui .add( - egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, ok_label)).color(egui::Color32::WHITE))) + egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(ok_label).color(egui::Color32::WHITE))) .fill(crate::theme::Tokens::get(ui.ctx()).accent_strong), ) .clicked() @@ -1717,7 +1650,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { 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(); + let cancel = d.id != "alert" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, cancel_label))).clicked(); if cancel || ui.input(|i| i.key_pressed(egui::Key::Escape)) { result = Some(false); } @@ -1749,13 +1682,12 @@ pub fn confirm(app: &mut DesignApp) -> Result { } match d.id.as_str() { "newDocument" => { - let margins = json!({"top": d.m("marginTop").unwrap_or(36.0), "bottom": d.m("marginBottom").unwrap_or(36.0), "inside": d.m("marginInside").unwrap_or(36.0), "outside": d.m("marginOutside").unwrap_or(36.0)}); - app.run( - "file.new", - json!({"width": d.m("width"), "height": d.m("height"), "pages": d.n("pages").unwrap_or(1.0) as u64, "facingPages": d.b("facingPages"), - "columns": d.n("columns").unwrap_or(1.0) as u64, "gutter": d.m("gutter").unwrap_or(12.0), "margins": margins, "bleed": d.m("bleed").unwrap_or(0.0), - "primaryTextFrame": d.b("primaryTextFrame")}), - ) + let p = crate::new_document::params(&d); + let r = app.run("file.new", p.clone()); + if r.is_ok() { + crate::new_document::remember(app, &p); + } + r } "frameSize" => { let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0); @@ -3813,7 +3745,9 @@ mod tests { fn dialog_title_and_actions_fit_scaled_viewports() { for language in ["en", "ar"] { for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { - for (command, title) in [("app.preferences", "Preferences"), ("app.newDocumentDialog", "New Document")] { + for (command, title, ok, cancel) in + [("app.preferences", "Preferences", "OK", "Cancel"), ("app.newDocumentDialog", "New Document", "Create", "Close")] + { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = language.into(); app.run("file.new", json!({})).unwrap(); @@ -3824,7 +3758,7 @@ mod tests { dialog_frame(&mut app, &ctx, screen, vec![]); } let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - for expected in [title, "OK", "Cancel"] { + for expected in [title, ok, cancel] { visible_label(&labels, screen, crate::i18n::tr(language, expected)); } } @@ -3882,8 +3816,20 @@ mod tests { assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers")); } + /// Scroll the dialog body down at `at` until `text` is painted unclipped on screen. + fn scroll_to_label(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, at: egui::Pos2, text: &str) -> egui::Rect { + for _ in 0..40 { + let labels = dialog_frame(app, ctx, screen, vec![]); + if let Some((_, rect, _)) = labels.iter().find(|(t, r, clip)| t.trim() == text && screen.contains_rect(*r) && clip.contains_rect(*r)) { + return *rect; + } + scroll_dialog(app, ctx, screen, at, egui::vec2(0.0, -60.0)); + } + panic!("{text} never scrolled into view"); + } + #[test] - fn new_document_vertical_scroll_reaches_last_field() { + fn new_document_vertical_scroll_reaches_every_field_and_preset() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("app.newDocumentDialog", json!({})).unwrap(); let ctx = dialog_context("en"); @@ -3893,20 +3839,23 @@ mod tests { } let labels = dialog_frame(&mut app, &ctx, screen, vec![]); let body = visible_label(&labels, screen, "Width").center(); - let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == "Primary Text Frame").unwrap(); - assert!(rect.bottom() > clip.bottom(), "last field starts vertically clipped"); - scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0)); + let shown = + |labels: &[(String, egui::Rect, egui::Rect)]| labels.iter().any(|(t, r, clip)| t == "Primary Text Frame" && clip.contains_rect(*r)); + assert!(!shown(&labels), "the details start vertically clipped"); + let ptf = scroll_to_label(&mut app, &ctx, screen, body, "Primary Text Frame"); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - for expected in ["Primary Text Frame", "New Document", "OK", "Cancel"] { + for expected in ["New Document", "Create", "Close"] { visible_label(&labels, screen, expected); } - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Primary Text Frame").center()); - assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame")); + click_dialog(&mut app, &ctx, screen, ptf.center()); + assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"), "the caption toggles its checkbox"); + // Below the details: the preset tabs and cards. + scroll_to_label(&mut app, &ctx, screen, body, "[Default]"); } #[test] - fn arabic_new_document_leftmost_fields_remain_reachable() { - for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { + fn arabic_new_document_fields_remain_reachable_and_editable() { + for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0), egui::vec2(1178.0, 814.0)] { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.language = "ar".into(); app.run("app.newDocumentDialog", json!({})).unwrap(); @@ -3919,20 +3868,8 @@ mod tests { for _ in 0..3 { dialog_frame(&mut app, &ctx, screen, vec![]); } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - for (_, value) in fields { - visible_label(&labels, screen, value); - } - let body = visible_label(&labels, screen, "66p1").center(); - for x in [-600.0, 600.0] { - scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(x, 0.0)); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - for (_, value) in fields { - visible_label(&labels, screen, value); - } - } - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "66p1").center()); + let height = visible_label(&dialog_frame(&mut app, &ctx, screen, vec![]), screen, "66p1"); + click_dialog(&mut app, &ctx, screen, height.center()); dialog_frame( &mut app, &ctx, @@ -3948,18 +3885,22 @@ mod tests { egui::Event::Text("88p8".into()), ], ); - assert_eq!(app.ui.dialog.as_ref().unwrap().s("height"), "88p8", "leftmost numeric field accepts edits"); - scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0)); - let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - let label = crate::i18n::tr("ar", "Primary Text Frame"); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, label).center()); - assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"), "leftmost checkbox remains clickable after scrolling"); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("height"), "88p8", "numeric fields accept edits"); + let caption = scroll_to_label(&mut app, &ctx, screen, height.center(), crate::i18n::tr("ar", "Primary Text Frame")); + click_dialog(&mut app, &ctx, screen, caption.center()); + assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"), "checkboxes remain clickable"); + for (_, value) in &fields[1..] { + scroll_to_label(&mut app, &ctx, screen, height.center(), value); + } } } #[test] fn resized_dialogs_cancel_and_reopen_without_applying_edits() { - for (command, title, field) in [("app.preferences", "Preferences", "recoveryMinutes"), ("app.newDocumentDialog", "New Document", "pages")] { + for (command, title, field, ok, cancel) in [ + ("app.preferences", "Preferences", "recoveryMinutes", "OK", "Cancel"), + ("app.newDocumentDialog", "New Document", "pages", "Create", "Close"), + ] { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run(command, json!({})).unwrap(); let original = app.ui.dialog.as_ref().unwrap().fields[field].clone(); @@ -3971,13 +3912,13 @@ mod tests { dialog_frame(&mut app, &ctx, screen, vec![]); } let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - for expected in [title, "OK", "Cancel"] { + for expected in [title, ok, cancel] { visible_label(&labels, screen, expected); } } let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0)); let labels = dialog_frame(&mut app, &ctx, screen, vec![]); - click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Cancel").center()); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, cancel).center()); assert!(app.ui.dialog.is_none()); assert!(app.session.active().is_none(), "cancelling must not create a document"); app.run(command, json!({})).unwrap(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index e5e8a8d08..a94273903 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2811,6 +2811,64 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), + // File › New › Document + ("Recent", ["Zuletzt verwendet", "Récents", "Recientes", "最近使用", "最近"]), + ("Saved", ["Gespeichert", "Enregistrés", "Guardados", "保存済み", "已保存"]), + ("[Default]", ["[Standard]", "[Par défaut]", "[Predeterminado]", "[初期設定]", "[默认]"]), + ("Your Recent Items", ["Zuletzt verwendete Elemente", "Éléments récents", "Elementos recientes", "最近使用した項目", "最近使用的项目"]), + ( + "Saved Presets", + ["Gespeicherte Vorgaben", "Paramètres prédéfinis enregistrés", "Ajustes preestablecidos guardados", "保存済みプリセット", "已保存的预设"], + ), + ( + "Blank Document Presets", + [ + "Vorgaben für leere Dokumente", + "Paramètres prédéfinis de document vierge", + "Ajustes preestablecidos de documento en blanco", + "空のドキュメントプリセット", + "空白文档预设", + ], + ), + ( + "No saved presets yet. Set the details, then click Save Document Preset.", + [ + "Noch keine gespeicherten Vorgaben. Legen Sie die Details fest und klicken Sie auf Dokumentvorgabe speichern.", + "Aucun paramètre prédéfini enregistré. Définissez les détails, puis cliquez sur Enregistrer le paramètre prédéfini de document.", + "Aún no hay ajustes preestablecidos guardados. Defina los detalles y haga clic en Guardar ajuste preestablecido de documento.", + "保存済みのプリセットはありません。詳細を設定してから「ドキュメントプリセットを保存」をクリックしてください。", + "尚无已保存的预设。请设置详细信息,然后单击“存储文档预设”。", + ], + ), + ("Delete Preset", ["Vorgabe löschen", "Supprimer le paramètre prédéfini", "Eliminar ajuste preestablecido", "プリセットを削除", "删除预设"]), + ( + "Preset Details", + ["Vorgabedetails", "Détails du paramètre prédéfini", "Detalles del ajuste preestablecido", "プリセットの詳細", "预设详细信息"], + ), + ("Document Name", ["Dokumentname", "Nom du document", "Nombre del documento", "ドキュメント名", "文档名称"]), + ( + "Save Document Preset", + [ + "Dokumentvorgabe speichern", + "Enregistrer le paramètre prédéfini de document", + "Guardar ajuste preestablecido de documento", + "ドキュメントプリセットを保存", + "存储文档预设", + ], + ), + ( + "Save Document Preset As:", + [ + "Dokumentvorgabe speichern unter:", + "Enregistrer le paramètre prédéfini de document sous :", + "Guardar ajuste preestablecido de documento como:", + "ドキュメントプリセットの保存名:", + "存储文档预设为:", + ], + ), + ("Save Preset", ["Vorgabe speichern", "Enregistrer le paramètre prédéfini", "Guardar ajuste preestablecido", "プリセットを保存", "存储预设"]), + ("Start #", ["Startseitennr.", "N° de début", "N.º inicial", "開始ページ番号", "起始页码"]), + ("Column Gutter", ["Spaltenabstand", "Gouttière", "Medianil", "列間隔", "栏间距"]), ]; /// 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..c884b955d 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1492,4 +1492,23 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Paragraph Rules", "Filetti paragrafo"), ("Rule Below", "Filetto sotto"), ("Tint:", "Tinta:"), + // File › New › Document + ("Recent", "Recenti"), + ("Saved", "Salvati"), + ("[Default]", "[Predefinito]"), + ("Your Recent Items", "Elementi recenti"), + ("Saved Presets", "Predefiniti salvati"), + ("Blank Document Presets", "Predefiniti documento vuoto"), + ( + "No saved presets yet. Set the details, then click Save Document Preset.", + "Nessun predefinito salvato. Imposta i dettagli, poi fai clic su Salva predefinito documento.", + ), + ("Delete Preset", "Elimina predefinito"), + ("Preset Details", "Dettagli predefinito"), + ("Document Name", "Nome documento"), + ("Save Document Preset", "Salva predefinito documento"), + ("Save Document Preset As:", "Salva predefinito documento come:"), + ("Save Preset", "Salva predefinito"), + ("Start #", "N. iniziale"), + ("Column Gutter", "Spazio tra colonne"), ]; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 69415e9b3..ae3637102 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -496,6 +496,15 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { } } // ---- Properties panel glyphs (original drawings) + "clock" => { + pen.circle(10.0, 10.0, 7.5); + pen.line(&[(10.0, 5.5), (10.0, 10.0), (13.5, 12.5)]); + } + "preset-save" => { + pen.line(&[(3.0, 11.0), (3.0, 17.0), (17.0, 17.0), (17.0, 11.0)]); + pen.line(&[(10.0, 3.0), (10.0, 13.0)]); + pen.line(&[(6.5, 9.5), (10.0, 13.0), (13.5, 9.5)]); + } "orient-portrait" => { pen.rect(5.5, 3.0, 14.5, 17.0); pen.fcircle(10.0, 8.0, 1.8); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c34..0ebd8746c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dock; pub mod i18n; pub mod icons; pub mod menus; +mod new_document; pub mod panels; pub mod render_worker; mod rtl; @@ -222,6 +223,10 @@ pub struct UiState { pub workspace: String, /// Window › Workspace › New Workspace: saved panel arrangements. pub custom_workspaces: Vec, + /// File › New › Document › Saved: document presets saved by name (`title`). + pub document_presets: Vec, + /// File › New › Document › Recent: the settings of the last documents made, newest first. + pub recent_new_documents: Vec, /// Transform reference point (0..8, row-major; 0 = top-left). pub ref_point: u8, /// Align To target (`selection`, `keyObject`, `margins`, `page`, `spread`). @@ -314,6 +319,8 @@ impl Default for UiState { units: Unit::Picas, workspace: "Essentials".into(), custom_workspaces: Vec::new(), + document_presets: Vec::new(), + recent_new_documents: Vec::new(), ref_point: 0, align_to: "selection".into(), text_style_tab: 0, diff --git a/crates/ui-egui/src/new_document.rs b/crates/ui-egui/src/new_document.rs new file mode 100644 index 000000000..6e9c1dda5 --- /dev/null +++ b/crates/ui-egui/src/new_document.rs @@ -0,0 +1,847 @@ +//! File › New › Document, laid out like InDesign's New Document window: preset tabs (Recent, +//! Saved, Print, Web, Mobile) over page cards on one side, the chosen preset's details on the +//! other. Every value lives in the dialog's fields, so the control channel sets them +//! (`ui.dialog.set`) like a user would; `confirm` turns them into `file.new`. + +use designcraft_doc::Intent; +use designcraft_doc::build::{NewDocument, PRESETS}; +use designcraft_geom::units::{PT_PER_MM, parse_measure}; +use designcraft_geom::{Unit, format_measure}; +use egui::{Align, Align2, Color32, FontId, Layout, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Map, Value, json}; + +use crate::DesignApp; +use crate::dialogs::Dialog; +use crate::theme::{Tokens, semibold}; + +/// Tab key and English label. +const TABS: [(&str, &str); 5] = [("recent", "Recent"), ("saved", "Saved"), ("print", "Print"), ("web", "Web"), ("mobile", "Mobile")]; +/// The card of the factory settings, first on the Recent tab. +const DEFAULT_CARD: &str = "[Default]"; +/// The details pane (and the stacked layout's width below `TWO_PANES`). +const DETAILS_W: f32 = 300.0; +const TWO_PANES: f32 = 700.0; +const COLUMN_W: f32 = 124.0; +const FIELD_H: f32 = 24.0; +const CARD: egui::Vec2 = egui::vec2(132.0, 150.0); +const MAX_RECENT: usize = 12; +const EDGES: [&str; 4] = ["Top", "Bottom", "Inside", "Outside"]; +/// Edge groups: field prefix (`marginTop`, `bleedTop`, `slugTop`). +const GROUPS: [&str; 3] = ["margin", "bleed", "slug"]; + +/// The dialog's fields for `nd`, its measures in its own units. +fn fields_for(nd: &NewDocument) -> Map { + let u = nd.units; + let m = |v: f64| json!(format_measure(v, u)); + let mut f = Map::new(); + f.insert("intent".into(), json!(nd.intent)); + f.insert("units".into(), json!(u)); + f.insert("width".into(), m(nd.width)); + f.insert("height".into(), m(nd.height)); + f.insert("pages".into(), json!(nd.pages)); + f.insert("startPage".into(), json!(nd.start_page)); + f.insert("facingPages".into(), json!(nd.facing_pages)); + f.insert("primaryTextFrame".into(), json!(nd.primary_text_frame)); + f.insert("columns".into(), json!(nd.columns)); + f.insert("gutter".into(), m(nd.gutter)); + let margins = [nd.margins.top, nd.margins.bottom, nd.margins.inside, nd.margins.outside]; + for (group, values) in GROUPS.into_iter().zip([margins, nd.bleed, nd.slug]) { + for (edge, v) in EDGES.into_iter().zip(values) { + f.insert(format!("{group}{edge}"), m(v)); + } + f.insert(format!("{group}Linked"), json!(values.iter().all(|v| (v - values[0]).abs() < 1e-6))); + } + f +} + +/// The fields a new New Document dialog starts with: the factory settings on the Recent tab. +pub(crate) fn defaults() -> Value { + let mut f = fields_for(&NewDocument::default()); + f.insert("tab".into(), json!("recent")); + f.insert("preset".into(), json!(DEFAULT_CARD)); + f.insert("name".into(), json!("")); + f.insert("presetName".into(), json!("")); + Value::Object(f) +} + +fn units(d: &Dialog) -> Unit { + d.fields.get("units").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(Unit::Picas) +} + +/// A measure field in points: a number is points, text is read in the dialog's units. +fn measure(d: &Dialog, key: &str) -> Option { + match d.fields.get(key)? { + Value::Number(n) => n.as_f64(), + Value::String(s) => parse_measure(s, units(d)).ok(), + _ => None, + } + .filter(|v| v.is_finite()) +} + +fn count(d: &Dialog, key: &str) -> u64 { + d.n(key).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(1.0, 9999.0).round() as u64 +} + +/// Every measure field, for a change of units. +fn measure_keys() -> Vec { + let mut keys: Vec = ["width", "height", "gutter"].into_iter().map(String::from).collect(); + keys.extend(GROUPS.into_iter().flat_map(|g| EDGES.into_iter().map(move |e| format!("{g}{e}")))); + keys +} + +/// `file.new` parameters for the dialog's fields. +pub(crate) fn params(d: &Dialog) -> Value { + let m = |k: &str, default: f64| measure(d, k).unwrap_or(default).max(0.0); + let edges = |group: &str| EDGES.map(|e| m(&format!("{group}{e}"), 0.0)); + // A control client may still set one `bleed` for every edge. + let bleed = measure(d, "bleed").map_or_else(|| json!(edges("bleed")), |b| json!(b.max(0.0))); + let margins = edges("margin"); + let mut p = json!({ + "intent": d.fields.get("intent").cloned().unwrap_or(json!("print")), + "units": units(d), + "width": measure(d, "width"), + "height": measure(d, "height"), + "pages": count(d, "pages"), + "startPage": count(d, "startPage"), + "facingPages": d.b("facingPages"), + "columns": count(d, "columns"), + "gutter": m("gutter", 12.0), + "margins": {"top": margins[0], "bottom": margins[1], "inside": margins[2], "outside": margins[3]}, + "bleed": bleed, + "slug": edges("slug"), + "primaryTextFrame": d.b("primaryTextFrame"), + }); + let name = d.s("name"); + if !name.trim().is_empty() { + p["title"] = json!(name.trim()); + } + p +} + +/// The settings `file.new` was given, as a preset (no title). +fn as_preset(p: &Value) -> Option { + let mut v = p.clone(); + let o = v.as_object_mut()?; + o.remove("title"); + o.retain(|_, v| !v.is_null()); + if let Some(b) = o.get("bleed").and_then(Value::as_f64) { + o.insert("bleed".into(), json!([b, b, b, b])); + } + serde_json::from_value(v).ok() +} + +/// Remember a document just made with `p` for the Recent tab. +pub(crate) fn remember(app: &mut DesignApp, p: &Value) { + let Some(nd) = as_preset(p) else { return }; + let recent = &mut app.ui.recent_new_documents; + recent.retain(|r| *r != nd); + recent.insert(0, nd); + recent.truncate(MAX_RECENT); +} + +/// Save the details as the document preset `name` (replacing one of that name) and show it. +fn save_preset(app: &mut DesignApp, d: &mut Dialog, name: &str) { + let name = name.trim(); + let Some(mut nd) = as_preset(¶ms(d)) else { return }; + if name.is_empty() { + return; + } + nd.title = name.to_string(); + app.ui.document_presets.retain(|p| p.title != name); + app.ui.document_presets.push(nd); + d.fields.insert("tab".into(), json!("saved")); + d.fields.insert("preset".into(), json!(format!("saved:{name}"))); +} + +/// The built-in preset `nd` is the size of, if any. +fn preset_name(nd: &NewDocument) -> Option<&'static str> { + PRESETS.iter().find(|p| p.intent == nd.intent && (p.width - nd.width).abs() < 0.01 && (p.height - nd.height).abs() < 0.01).map(|p| p.name) +} + +struct Card { + key: String, + label: String, + nd: NewDocument, + /// Not a built-in size: drawn with crop marks. + custom: bool, +} + +fn cards(app: &DesignApp, tab: &str) -> Vec { + let lang = &app.ui.language; + let named = |key: String, nd: NewDocument| { + let (label, custom) = match preset_name(&nd) { + Some(n) => (crate::i18n::tr(lang, n).to_string(), false), + None => (crate::i18n::tr(lang, "Custom").to_string(), true), + }; + Card { key, label, nd, custom } + }; + match tab { + "recent" => { + let default = + Card { key: DEFAULT_CARD.into(), label: crate::i18n::tr(lang, DEFAULT_CARD).into(), nd: NewDocument::default(), custom: false }; + std::iter::once(default) + .chain(app.ui.recent_new_documents.iter().enumerate().map(|(i, nd)| named(format!("recent:{i}"), nd.clone()))) + .collect() + } + "saved" => app + .ui + .document_presets + .iter() + .map(|nd| Card { key: format!("saved:{}", nd.title), label: nd.title.clone(), nd: nd.clone(), custom: preset_name(nd).is_none() }) + .collect(), + tab => { + let intent = match tab { + "web" => Intent::Web, + "mobile" => Intent::Mobile, + _ => Intent::Print, + }; + PRESETS + .iter() + .filter(|p| p.intent == intent) + .filter_map(|p| { + NewDocument::from_preset(p.name).map(|nd| Card { + key: p.name.into(), + label: crate::i18n::tr(lang, p.name).into(), + nd, + custom: false, + }) + }) + .collect() + } + } +} + +/// `210 × 297 mm`, `51p0 × 66p0`. +fn size_text(nd: &NewDocument) -> String { + let u = nd.units; + let (w, h) = (format_measure(nd.width, u), format_measure(nd.height, u)); + let suffix = u.suffix(); + if suffix.starts_with(' ') + && let (Some(a), Some(b)) = (w.strip_suffix(suffix), h.strip_suffix(suffix)) + { + return format!("{a} × {b}{suffix}"); + } + format!("{w} × {h}") +} + +/// A page with a folded corner, its proportions those of the preset; crop marks for a custom size. +fn paint_page(p: &egui::Painter, area: Rect, nd: &NewDocument, custom: bool, color: Color32) { + let aspect = if nd.height > 0.0 { (nd.width / nd.height).clamp(0.2, 5.0) as f32 } else { 1.0 }; + let size = + if aspect >= area.width() / area.height() { vec2(area.width(), area.width() / aspect) } else { vec2(area.height() * aspect, area.height()) }; + let r = Rect::from_center_size(area.center(), size); + let fold = (size.min_elem() * 0.24).clamp(5.0, 14.0); + let stroke = Stroke::new(1.5, color); + let corner = pos2(r.right() - fold, r.top()); + p.add(egui::Shape::line(vec![r.left_top(), corner, pos2(r.right(), r.top() + fold), r.right_bottom(), r.left_bottom(), r.left_top()], stroke)); + p.add(egui::Shape::line(vec![corner, pos2(corner.x, r.top() + fold), pos2(r.right(), r.top() + fold)], stroke)); + if custom { + let (gap, len) = (5.0, 10.0); + p.line_segment([pos2(r.left() - gap - len, r.top() - gap), pos2(r.left() - gap, r.top() - gap)], stroke); + p.line_segment([pos2(r.left() - gap, r.top() - gap - len), pos2(r.left() - gap, r.top() - gap)], stroke); + } +} + +fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(CARD, Sense::click()); + if selected { + ui.painter().rect(r.shrink(1.0), 3.0, t.hover, Stroke::new(2.0, t.accent), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r, 3.0, t.hover); + } + let page = Rect::from_center_size(pos2(r.center().x, r.top() + 56.0), vec2(72.0, 72.0)); + paint_page(ui.painter(), page, &c.nd, c.custom, if selected { t.text_strong } else { t.icon }); + crate::rtl::paint(ui.painter(), pos2(r.center().x, r.top() + 114.0), Align2::CENTER_CENTER, &c.label, semibold(12.0), t.text_strong); + crate::rtl::paint( + ui.painter(), + pos2(r.center().x, r.top() + 132.0), + Align2::CENTER_CENTER, + &size_text(&c.nd), + FontId::proportional(11.0), + t.text_dim, + ); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &c.label)); + resp +} + +/// 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::TOP) } else { Layout::left_to_right(Align::TOP) }; + ui.with_layout(layout, add).inner +} + +fn tabs(app: &DesignApp, ui: &mut Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let rtl = crate::i18n::is_rtl(&app.ui.language); + let current = d.s("tab"); + let strip = row(ui, rtl, |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (key, label) in TABS { + let text = crate::i18n::tr(&app.ui.language, label); + let on = current == key; + let font = if on { semibold(13.0) } else { FontId::proportional(13.0) }; + let text_w = ui.painter().layout_no_wrap(text.to_string(), font.clone(), t.text).size().x; + let icon_w = if key == "recent" { 20.0 } else { 0.0 }; + let (r, resp) = ui.allocate_exact_size(vec2(text_w + icon_w + 20.0, 34.0), Sense::click()); + let color = if on || resp.hovered() { t.text_strong } else { t.text_dim }; + let start = if rtl { r.right() - 10.0 - icon_w } else { r.left() + 10.0 }; + if key == "recent" { + let icon = Rect::from_min_size(pos2(if rtl { r.right() - 26.0 } else { r.left() + 8.0 }, r.center().y - 9.0), vec2(16.0, 16.0)); + crate::icons::paint(ui.painter(), icon, "clock", color); + } + let anchor = if rtl { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER }; + let x = if rtl { start } else { start + icon_w }; + crate::rtl::paint(ui.painter(), pos2(x, r.center().y - 1.0), anchor, text, font, color); + if on { + ui.painter().rect_filled( + Rect::from_min_max(pos2(r.left() + 6.0, r.bottom() - 2.0), pos2(r.right() - 6.0, r.bottom())), + 1.0, + t.text_strong, + ); + } + if resp.clicked() { + d.fields.insert("tab".into(), json!(key)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, text)); + } + ui.min_rect() + }); + let y = strip.bottom(); + ui.painter().line_segment([pos2(ui.min_rect().left(), y), pos2(ui.max_rect().right(), y)], Stroke::new(1.0, t.divider)); +} + +/// The cards of the current tab; true when one was double-clicked (create at once). +fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let rtl = crate::i18n::is_rtl(&lang); + let tab = d.s("tab"); + let list = cards(app, &tab); + let heading = match tab.as_str() { + "recent" => "Your Recent Items", + "saved" => "Saved Presets", + _ => "Blank Document Presets", + }; + row(ui, rtl, |ui| { + crate::rtl::label( + ui, + egui::RichText::new(format!("{} ({})", crate::i18n::tr(&lang, heading).to_uppercase(), list.len())) + .font(semibold(11.5)) + .color(t.text_dim), + ); + }); + ui.add_space(10.0); + if list.is_empty() { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&lang, "No saved presets yet. Set the details, then click Save Document Preset.")).color(t.text_dim), + ); + return false; + } + let current = d.s("preset"); + let mut create = false; + let mut delete = None; + let layout = if rtl { Layout::right_to_left(Align::TOP) } else { Layout::left_to_right(Align::TOP) }.with_main_wrap(true); + ui.with_layout(layout, |ui| { + ui.spacing_mut().item_spacing = vec2(10.0, 10.0); + for c in &list { + let resp = card(ui, c, current == c.key); + if resp.clicked() || resp.double_clicked() { + let name = d.s("name"); + d.fields.extend(fields_for(&c.nd)); + d.fields.insert("name".into(), json!(name)); + d.fields.insert("preset".into(), json!(c.key)); + } + if resp.double_clicked() { + create = true; + } + if tab == "saved" { + resp.context_menu(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Delete Preset"))).clicked() { + delete = Some(c.nd.title.clone()); + ui.close(); + } + }); + } + } + }); + if let Some(name) = delete { + app.ui.document_presets.retain(|p| p.title != name); + } + create +} + +#[derive(Clone, Copy)] +enum Step { + /// A measure no smaller than this many points. + Measure(f64), + /// A whole number from 1. + Count, +} + +/// One arrow click: a unit's usual increment. +fn increment(u: Unit) -> f64 { + match u { + Unit::Millimeters | Unit::Centimeters => PT_PER_MM, + Unit::Inches | Unit::InchesDecimal => 4.5, + _ => 1.0, + } +} + +fn nudge(d: &mut Dialog, key: &str, dir: f64, step: Step) { + match step { + Step::Count => { + let v = (count(d, key) as f64 + dir).clamp(1.0, 9999.0); + d.fields.insert(key.into(), json!(v as u64)); + } + Step::Measure(min) => { + let u = units(d); + let v = (measure(d, key).unwrap_or(min) + dir * increment(u)).clamp(min, 15552.0); + d.fields.insert(key.into(), json!(format_measure(v, u))); + } + } +} + +/// A field with up/down arrows at its start; true when its value changed. +fn spin(ui: &mut Ui, d: &mut Dialog, key: &str, width: f32, step: Step) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + ui.painter().rect(r, 2.0, t.input, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let seg = Rect::from_min_size(r.min, vec2(18.0, FIELD_H)); + ui.painter().line_segment([seg.right_top(), seg.right_bottom()], Stroke::new(1.0, t.field_border)); + let mut changed = false; + for (dir, half) in + [(1.0, Rect::from_min_max(seg.min, pos2(seg.max.x, seg.center().y))), (-1.0, Rect::from_min_max(pos2(seg.min.x, seg.center().y), seg.max))] + { + let resp = ui.interact(half, ui.id().with((key, dir < 0.0)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(half.shrink(1.0), 1.0, t.hover); + } + let c = half.center(); + let s = 3.0 * dir as f32; + ui.painter().add(egui::Shape::line( + vec![pos2(c.x - 3.5, c.y + s / 2.0), pos2(c.x, c.y - s / 2.0), pos2(c.x + 3.5, c.y + s / 2.0)], + Stroke::new(1.2, t.icon), + )); + if resp.clicked() { + nudge(d, key, dir, step); + changed = true; + } + } + let text_rect = Rect::from_min_max(pos2(seg.right() + 6.0, r.top() + 1.0), pos2(r.right() - 4.0, r.bottom() - 1.0)); + let mut s = d.s(key); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect).layout(Layout::left_to_right(Align::Center))); + let resp = child.add( + egui::TextEdit::singleline(&mut s) + .id(ui.id().with((key, "text"))) + .frame(egui::Frame::NONE) + .desired_width(text_rect.width()) + .horizontal_align(Align::Min) + .vertical_align(Align::Center), + ); + if resp.changed() { + d.fields.insert(key.into(), Value::String(s)); + changed = true; + } + if resp.has_focus() { + let (up, down) = child + .input_mut(|i| (i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown))); + for (pressed, dir) in [(up, 1.0), (down, -1.0)] { + if pressed { + nudge(d, key, dir, step); + changed = true; + } + } + } + changed +} + +/// A captioned control in one of the details' two columns. +fn cell(ui: &mut Ui, rtl: bool, caption: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + cell_w(ui, rtl, COLUMN_W, caption, add) +} + +fn cell_w(ui: &mut Ui, rtl: bool, width: f32, caption: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + let layout = Layout::top_down(if rtl { Align::Max } else { Align::Min }); + ui.allocate_ui_with_layout(vec2(width, 2.0 * FIELD_H), layout, |ui| { + ui.set_width(width); + ui.spacing_mut().item_spacing.y = 3.0; + let t = Tokens::get(ui.ctx()); + crate::rtl::label(ui, egui::RichText::new(caption).size(11.5).color(t.text)); + add(ui) + }) + .inner +} + +/// A captioned checkbox; the caption toggles it too. +fn check_cell(ui: &mut Ui, rtl: bool, d: &mut Dialog, key: &str, caption: &str) { + let layout = Layout::top_down(if rtl { Align::Max } else { Align::Min }); + ui.allocate_ui_with_layout(vec2(COLUMN_W, 2.0 * FIELD_H), layout, |ui| { + ui.set_width(COLUMN_W); + ui.spacing_mut().item_spacing.y = 3.0; + let t = Tokens::get(ui.ctx()); + let mut on = d.b(key); + let label = ui.add(egui::Label::new(crate::rtl::widget(ui, egui::RichText::new(caption).size(11.5).color(t.text))).sense(Sense::click())); + let boxed = ui.add_sized(vec2(FIELD_H, FIELD_H), egui::Checkbox::without_text(&mut on)); + if label.clicked() { + on = !on; + } + if label.clicked() || boxed.changed() { + d.fields.insert(key.into(), json!(on)); + } + }); +} + +/// Four edges (`{group}Top`…) beside a link that keeps them equal. +fn edges(ui: &mut Ui, lang: &str, d: &mut Dialog, group: &str) { + let rtl = crate::i18n::is_rtl(lang); + let linked_key = format!("{group}Linked"); + let linked = d.b(&linked_key); + let facing = d.b("facingPages"); + let labels = if facing { EDGES } else { ["Top", "Bottom", "Left", "Right"] }; + let (mut changed, mut source) = (None, None); + let link_w = 30.0; + let column = ((ui.available_width() - link_w - 3.0 * ui.spacing().item_spacing.x) / 2.0).clamp(60.0, COLUMN_W); + row(ui, rtl, |ui| { + ui.vertical(|ui| { + for pair in [[0, 1], [2, 3]] { + row(ui, rtl, |ui| { + for i in pair { + let key = format!("{group}{}", EDGES[i]); + if cell_w(ui, rtl, column, crate::i18n::tr(lang, labels[i]), |ui| spin(ui, d, &key, column - 8.0, Step::Measure(0.0))) { + changed = Some(key); + } + } + }); + } + }); + ui.add_space(2.0); + ui.vertical(|ui| { + ui.add_space(FIELD_H + 12.0); + let icon = if linked { "link" } else { "link-broken" }; + if crate::widgets::icon_toggle_sized(ui, icon, linked, crate::i18n::tr(lang, "Make all settings the same"), vec2(link_w, link_w)) + .clicked() + { + d.fields.insert(linked_key.clone(), json!(!linked)); + // Linking makes every edge the top's. + if !linked { + source = Some(format!("{group}Top")); + } + } + }); + }); + if linked && source.is_none() { + source = changed; + } + if let Some(key) = source { + link_edges(d, group, &key); + } +} + +/// Every edge of `group` takes the value of the field `from`. +fn link_edges(d: &mut Dialog, group: &str, from: &str) { + if let Some(v) = d.fields.get(from).cloned() { + for e in EDGES { + d.fields.insert(format!("{group}{e}"), v.clone()); + } + } +} + +/// Show the measures in `u` (same lengths). +fn set_units(d: &mut Dialog, u: Unit) { + let values: Vec<(String, Option)> = measure_keys().into_iter().map(|k| (k.clone(), measure(d, &k))).collect(); + d.fields.insert("units".into(), json!(u)); + for (k, pt) in values { + if let Some(pt) = pt { + d.fields.insert(k, json!(format_measure(pt, u))); + } + } +} + +fn section_rule(ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.divider)); + ui.add_space(6.0); +} + +fn details(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &'static str| crate::i18n::tr(&lang, s); + let rtl = crate::i18n::is_rtl(&lang); + ui.spacing_mut().item_spacing.y = 8.0; + row(ui, rtl, |ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Preset Details").to_uppercase()).font(semibold(11.5)).color(t.text_dim)); + }); + // Document name, and Save Document Preset. + row(ui, rtl, |ui| { + let mut name = d.s("name"); + let hint = app.session.next_untitled_title(); + let resp = ui.add(egui::TextEdit::singleline(&mut name).hint_text(hint).desired_width(2.0 * COLUMN_W - 10.0).font(semibold(13.0))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tr("Document Name"))); + if resp.changed() { + d.fields.insert("name".into(), json!(name)); + } + let save = crate::widgets::icon_toggle_sized(ui, "preset-save", false, tr("Save Document Preset"), vec2(30.0, 26.0)); + egui::Popup::menu(&save).show(|ui| { + ui.set_min_width(220.0); + crate::rtl::label(ui, tr("Save Document Preset As:")); + let mut preset = d.s("presetName"); + if ui.add(egui::TextEdit::singleline(&mut preset).hint_text(tr("Custom")).desired_width(200.0)).changed() { + d.fields.insert("presetName".into(), json!(preset)); + } + if ui.button(crate::rtl::widget(ui, tr("Save Preset"))).clicked() { + let name = if preset.trim().is_empty() { tr("Custom").to_string() } else { preset }; + save_preset(app, d, &name); + ui.close(); + } + }); + }); + let u = units(d); + row(ui, rtl, |ui| { + cell(ui, rtl, tr("Width"), |ui| spin(ui, d, "width", COLUMN_W - 8.0, Step::Measure(1.0))); + cell(ui, rtl, tr("Units"), |ui| { + egui::ComboBox::from_id_salt("nd_units").selected_text(crate::rtl::widget(ui, tr(u.label()))).width(COLUMN_W - 8.0).show_ui(ui, |ui| { + for v in Unit::ALL { + if ui.selectable_label(v == u, crate::rtl::widget(ui, tr(v.label()))).clicked() && v != u { + set_units(d, v); + } + } + }); + }); + }); + row(ui, rtl, |ui| { + cell(ui, rtl, tr("Height"), |ui| spin(ui, d, "height", COLUMN_W - 8.0, Step::Measure(1.0))); + cell(ui, rtl, tr("Orientation"), |ui| { + let (w, h) = (measure(d, "width"), measure(d, "height")); + let portrait = !matches!((w, h), (Some(w), Some(h)) if w > h); + row(ui, rtl, |ui| { + for (icon, tip, on) in [("orient-portrait", "Portrait", portrait), ("orient-landscape", "Landscape", !portrait)] { + if crate::widgets::icon_toggle_sized(ui, icon, on, tr(tip), vec2(30.0, FIELD_H)).clicked() && !on { + let (w, h) = (d.fields.get("width").cloned(), d.fields.get("height").cloned()); + if let (Some(w), Some(h)) = (w, h) { + d.fields.insert("width".into(), h); + d.fields.insert("height".into(), w); + } + } + } + }); + }); + }); + row(ui, rtl, |ui| { + cell(ui, rtl, tr("Pages"), |ui| spin(ui, d, "pages", COLUMN_W - 8.0, Step::Count)); + check_cell(ui, rtl, d, "facingPages", tr("Facing Pages")); + }); + row(ui, rtl, |ui| { + cell(ui, rtl, tr("Start #"), |ui| spin(ui, d, "startPage", COLUMN_W - 8.0, Step::Count)); + check_cell(ui, rtl, d, "primaryTextFrame", tr("Primary Text Frame")); + }); + section_rule(ui); + row(ui, rtl, |ui| { + cell(ui, rtl, tr("Columns"), |ui| spin(ui, d, "columns", COLUMN_W - 8.0, Step::Count)); + cell(ui, rtl, tr("Column Gutter"), |ui| spin(ui, d, "gutter", COLUMN_W - 8.0, Step::Measure(0.0))); + }); + section_rule(ui); + egui::CollapsingHeader::new(crate::rtl::widget(ui, egui::RichText::new(tr("Margins")).size(12.0).color(t.text))) + .id_salt("nd_margins") + .default_open(true) + .show(ui, |ui| edges(ui, &lang, d, "margin")); + section_rule(ui); + egui::CollapsingHeader::new(crate::rtl::widget(ui, egui::RichText::new(tr("Bleed and Slug")).size(12.0).color(t.text))) + .id_salt("nd_bleed_slug") + .default_open(true) + .show(ui, |ui| { + row(ui, rtl, |ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Bleed")).size(11.5).color(t.text_dim)); + }); + edges(ui, &lang, d, "bleed"); + row(ui, rtl, |ui| { + crate::rtl::label(ui, egui::RichText::new(tr("Slug")).size(11.5).color(t.text_dim)); + }); + edges(ui, &lang, d, "slug"); + }); +} + +/// The window's body; true when a card was double-clicked (create the document now). +pub(crate) fn body(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog, width: f32, height: f32) -> bool { + let t = Tokens::get(ui.ctx()); + let rtl = crate::i18n::is_rtl(&app.ui.language); + let mut create = false; + if width < TWO_PANES { + // A small window: the details first, then the presets; the dialog body scrolls. + ui.set_max_width(DETAILS_W.max(width)); + details(app, ui, d); + ui.add_space(14.0); + tabs(app, ui, d); + ui.add_space(12.0); + return presets(app, ui, d); + } + let pane_h = height.clamp(FIELD_H, 560.0); + let presets_w = (width - DETAILS_W - 16.0).max(CARD.x + 24.0); + row(ui, rtl, |ui| { + ui.allocate_ui_with_layout(vec2(presets_w, pane_h), Layout::top_down(if rtl { Align::Max } else { Align::Min }), |ui| { + ui.set_min_size(vec2(presets_w, pane_h)); + tabs(app, ui, d); + ui.add_space(12.0); + let rest = ui.available_height().max(FIELD_H); + egui::Frame::NONE.fill(t.panel_darker).corner_radius(4.0).inner_margin(12.0).show(ui, |ui| { + ui.set_min_size(vec2(presets_w - 24.0, rest - 24.0)); + egui::ScrollArea::vertical().id_salt("nd_presets").max_height(rest - 24.0).auto_shrink([false, false]).show(ui, |ui| { + create = presets(app, ui, d); + }); + }); + }); + ui.add_space(16.0); + ui.allocate_ui_with_layout(vec2(DETAILS_W, pane_h), Layout::top_down(if rtl { Align::Max } else { Align::Min }), |ui| { + ui.set_min_size(vec2(DETAILS_W, pane_h)); + egui::ScrollArea::vertical().id_salt("nd_details").max_height(pane_h).auto_shrink([false, false]).show(ui, |ui| { + ui.set_max_width(DETAILS_W - 12.0); + details(app, ui, d); + }); + }); + }); + create +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("app.newDocumentDialog", json!({})).unwrap(); + app + } + + fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, ""); + crate::theme::apply(&ctx, &Tokens::for_brightness(crate::theme::Brightness::default())); + ctx + } + + /// One frame of the open dialog → the painted texts and where. + fn frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 900.0))), + time: Some(ctx.input(|i| i.time) + 1.0 / 60.0), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + fn collect(shape: &egui::Shape, texts: &mut Vec<(String, Rect)>) { + match shape { + egui::Shape::Text(t) => texts.push((t.galley.job.text.clone(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, texts)), + _ => {} + } + } + let mut texts = Vec::new(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut texts)); + texts + } + + fn settle(app: &mut DesignApp, ctx: &egui::Context) -> Vec<(String, Rect)> { + for _ in 0..3 { + frame(app, ctx, vec![]); + } + frame(app, ctx, vec![]) + } + + fn click(app: &mut DesignApp, ctx: &egui::Context, pos: egui::Pos2, times: usize) { + frame(app, ctx, vec![egui::Event::PointerMoved(pos)]); + for _ in 0..times { + for pressed in [true, false] { + frame( + app, + ctx, + vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }], + ); + } + } + } + + /// The card whose name is `name` (its page, above the name). + fn card_at(texts: &[(String, Rect)], name: &str) -> egui::Pos2 { + let (_, r) = texts.iter().find(|(t, _)| t == name).unwrap_or_else(|| panic!("card {name}")); + r.center() - vec2(0.0, 50.0) + } + + #[test] + fn a_print_preset_fills_the_details_and_creates_its_document() { + let mut app = app(); + let ctx = context(); + app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("print")); + let texts = settle(&mut app, &ctx); + click(&mut app, &ctx, card_at(&texts, "A4"), 1); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.s("preset"), d.s("width"), d.s("height"), units(d)), ("A4".into(), "210 mm".into(), "297 mm".into(), Unit::Millimeters)); + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in [("startPage", json!(3)), ("bleedTop", json!("3 mm")), ("slugBottom", json!("10 mm")), ("name", json!("Flyer"))] { + d.fields.insert(k.into(), v); + } + crate::dialogs::confirm(&mut app).unwrap(); + let doc = &app.session.active().unwrap().doc; + assert_eq!(doc.title, "Flyer"); + assert!((doc.settings.page_width - 210.0 * PT_PER_MM).abs() < 1e-6); + assert_eq!(doc.settings.horizontal_units, Unit::Millimeters); + assert_eq!(doc.page_name(0), "3"); + assert!((doc.settings.bleed[0] - 3.0 * PT_PER_MM).abs() < 1e-6 && doc.settings.bleed[1] == 0.0); + assert!((doc.settings.slug[1] - 10.0 * PT_PER_MM).abs() < 1e-6); + // The Recent tab now shows it, by its preset's name. + let recent = cards(&app, "recent"); + assert_eq!(recent.iter().map(|c| c.label.as_str()).collect::>(), [DEFAULT_CARD, "A4"]); + assert_eq!(recent[1].nd.start_page, 3); + } + + #[test] + fn double_clicking_a_card_creates_the_document() { + let mut app = app(); + let ctx = context(); + app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("web")); + let texts = settle(&mut app, &ctx); + click(&mut app, &ctx, card_at(&texts, "Web 1366 \u{d7} 768"), 2); + assert!(app.ui.dialog.is_none(), "the dialog closed"); + let doc = &app.session.active().unwrap().doc; + assert_eq!((doc.settings.page_width, doc.settings.intent), (1366.0, Intent::Web)); + assert!(!doc.settings.facing_pages); + } + + #[test] + fn units_reformat_the_measures_and_linked_edges_follow() { + let mut d = Dialog::new("newDocument", json!({})); + set_units(&mut d, Unit::Millimeters); + assert_eq!((d.s("width"), d.s("marginTop")), ("215.9 mm".into(), "12.7 mm".into())); + d.fields.insert("marginInside".into(), json!("20 mm")); + link_edges(&mut d, "margin", "marginInside"); + assert!(EDGES.iter().all(|e| d.s(&format!("margin{e}")) == "20 mm")); + let p = params(&d); + assert!((p["margins"]["outside"].as_f64().unwrap() - 20.0 * PT_PER_MM).abs() < 1e-9); + // Typed nonsense falls back to the defaults instead of reaching the document. + d.fields.insert("gutter".into(), json!("wide")); + d.fields.insert("pages".into(), json!("-4")); + let p = params(&d); + assert_eq!((p["gutter"].as_f64(), p["pages"].as_u64()), (Some(12.0), Some(1))); + } + + #[test] + fn presets_are_saved_by_name_and_recent_documents_once() { + let mut app = app(); + let mut d = app.ui.dialog.clone().unwrap(); + save_preset(&mut app, &mut d, " Newsletter "); + d.fields.insert("pages".into(), json!(8)); + save_preset(&mut app, &mut d, "Newsletter"); + assert_eq!(app.ui.document_presets.len(), 1, "the same name replaces the preset"); + assert_eq!((d.s("tab"), d.s("preset")), ("saved".into(), "saved:Newsletter".into())); + let saved = cards(&app, "saved"); + assert_eq!((saved.len(), saved[0].label.as_str(), saved[0].nd.pages), (1, "Newsletter", 8)); + let p = params(&d); + remember(&mut app, &p); + remember(&mut app, &p); + assert_eq!(app.ui.recent_new_documents.len(), 1, "a repeat moves to the front"); + } +}