mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 11:46:51 -06:00
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:
co-authored by
Claude Opus 5.5
parent
92649ffdb0
commit
651ef6aec2
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶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<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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user