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 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-10 14:53:51 +02:00
co-authored by Claude Opus 5.5
parent 92649ffdb0
commit 651ef6aec2
9 changed files with 1062 additions and 128 deletions
+1
View File
@@ -166,6 +166,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes.
- Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`.
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML.
- 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.
+50 -3
View File
@@ -13,7 +13,7 @@ use crate::{DocState, EngineError, Result, Session};
pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
}
_ => {}
}
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<Value> {
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::<Option<Vec<f64>>>()?.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<Value> {
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<Value> {
}
}
#[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;
+5
View File
@@ -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 {
+66 -125
View File
@@ -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<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(),
@@ -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<Value, String> {
}
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();
+58
View File
@@ -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.
+19
View File
@@ -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"),
];
+9
View File
@@ -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);
+7
View File
@@ -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<SavedWorkspace>,
/// File › New › Document › Saved: document presets saved by name (`title`).
pub document_presets: Vec<designcraft_doc::build::NewDocument>,
/// File › New › Document › Recent: the settings of the last documents made, newest first.
pub recent_new_documents: Vec<designcraft_doc::build::NewDocument>,
/// 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,
+847
View File
@@ -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<String, Value> {
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<f64> {
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<String> {
let mut keys: Vec<String> = ["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<NewDocument> {
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(&params(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<Card> {
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<R>(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<R>(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<R>(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<f64>)> = 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<egui::Event>) -> 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::<Vec<_>>(), [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");
}
}