mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
Merge new-doc-preview: New Document: Preview shows the new document behind the window (#679)
# Conflicts: # ROADMAP.md # crates/engine/src/cmd/file.rs # crates/engine/src/lib.rs # crates/ui-egui/src/control.rs # crates/ui-egui/src/dialogs.rs # crates/ui-egui/src/i18n.rs # crates/ui-egui/src/i18n/it.rs # crates/ui-egui/src/lib.rs # crates/ui-egui/src/new_document.rs # docs/target-app-parity.md
This commit is contained in:
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-13** · New Document has Preview (off by default, kept for the session): a document with the window's settings shows in the workspace and follows them once typing pauses. Create keeps it with a clean undo history and refuses invalid fields; Close removes it and shows the previous document again. The preview takes no Untitled number and writes no recovery data; one edited by a menu or script stays open as a document. No readiness percentage change.
|
||||
- **2026-10-13** · Object styles carry effects (drop shadow, feather, inner and outer shadows and glows, bevel, satin, gradient and directional feather). New styles from a selection keep its effects, `style.object.edit` changes a style's effects and every item that follows them, `style.object.clearOverrides` returns items to their style, and new frames take their default style's effects. IDML carries only the drop shadow and feather, for ObjectStyles (with their effects category and BasedOn chain) as for items (#653). No readiness percentage change.
|
||||
- **2026-10-13** · A parent can be based on another parent (`layout.parents.options`). Pages draw the base parent's items, then the parent's, then their own, on screen and in PDF and image export; chains work, cycles are refused, overrides reach base items, `document.inspect` reports `basedOn`, and IDML round-trips it. No readiness percentage change.
|
||||
- **2026-10-12** · The Effects dialog, as in InDesign: Object › Effects (Transparency…, Drop Shadow… and the rest, and their shortcuts) and the Effects panel's fx button open it on the chosen effect, turned on. Transparency and every effect in one window, in the document's units, with X/Y offsets, Preview on the page, Cancel putting things back, and OK as one undo step (`object.effects`). Shadows default to InDesign's 120° light angle. No readiness percentage change.
|
||||
|
||||
@@ -172,13 +172,19 @@ fn find_preset(s: &Session, name: &str) -> Option<NewDocument> {
|
||||
NewDocument::from_preset(name).or_else(|| s.prefs.document_presets.iter().find(|p| p.title.eq_ignore_ascii_case(name.trim())).cloned())
|
||||
}
|
||||
|
||||
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The settings `file.new` makes a document with for `p`: its preset, then the settings over it.
|
||||
fn new_document_settings(s: &Session, p: &Value) -> Result<NewDocument> {
|
||||
let mut nd = match str_param(p, "preset") {
|
||||
Some(name) => find_preset(s, name).ok_or_else(|| bad("file.new", format!("unknown preset `{name}`")))?,
|
||||
None => NewDocument::default(),
|
||||
};
|
||||
apply_settings(&mut nd, p);
|
||||
check_size("file.new", &nd)?;
|
||||
Ok(nd)
|
||||
}
|
||||
|
||||
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut nd = new_document_settings(s, p)?;
|
||||
s.untitled += 1;
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| format!("Untitled-{}", s.untitled));
|
||||
let d = Document::new(&nd);
|
||||
@@ -186,6 +192,85 @@ fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// New Document's Preview: a document made as `file.new` would make it with `p`, shown while
|
||||
/// the New Document window is open. It replaces the previous preview document in its tab and
|
||||
/// takes no Untitled number, no undo step and no recovery entry. Settings `file.new` refuses
|
||||
/// leave the previous preview as it was. Returns the preview document's uid.
|
||||
pub fn preview_new_document(&mut self, p: &Value) -> Result<u64> {
|
||||
let mut nd = new_document_settings(self, p)?;
|
||||
let previous = match self.new_document_preview {
|
||||
Some((_, previous)) => previous,
|
||||
None => self.active().map(|d| d.uid),
|
||||
};
|
||||
let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old));
|
||||
// A preview edited or saved by a menu or script while the window is open has become a
|
||||
// document of its own: it stays, and the preview gets a tab of its own.
|
||||
let replace = shown.filter(|&i| self.docs.get(i).is_some_and(|d| !d.is_dirty() && d.path.is_none()));
|
||||
if replace.is_none() && shown.is_some() {
|
||||
self.untitled += 1;
|
||||
}
|
||||
nd.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| self.next_untitled_title());
|
||||
let st = DocState::new(Document::new(&nd), None);
|
||||
let uid = st.uid;
|
||||
match replace.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
|
||||
Some((i, d)) => {
|
||||
*d = st;
|
||||
self.active = Some(i);
|
||||
}
|
||||
None => {
|
||||
// Pushed without `add_document`: the document that was active stays as it is.
|
||||
self.docs.push(st);
|
||||
self.active = Some(self.docs.len() - 1);
|
||||
}
|
||||
}
|
||||
self.new_document_preview = Some((uid, previous));
|
||||
Ok(uid)
|
||||
}
|
||||
|
||||
/// The uid of New Document's preview document, while one is shown.
|
||||
pub fn new_document_preview(&self) -> Option<u64> {
|
||||
self.new_document_preview.map(|(uid, _)| uid)
|
||||
}
|
||||
|
||||
/// End New Document's preview. Its document goes, and if it was still the active document,
|
||||
/// the one that was active before it is active again. A preview that a menu or script has
|
||||
/// edited or saved stays open as a document, with an Untitled number. Returns the uid of the
|
||||
/// document removed. Without a preview it does nothing.
|
||||
pub fn end_new_document_preview(&mut self) -> Option<u64> {
|
||||
let (uid, previous) = self.new_document_preview.take()?;
|
||||
let i = self.docs.iter().position(|d| d.uid == uid)?;
|
||||
let d = self.docs.get(i)?;
|
||||
if d.is_dirty() || d.path.is_some() {
|
||||
self.untitled += 1;
|
||||
return None;
|
||||
}
|
||||
self.docs.remove(i);
|
||||
self.active = match self.active {
|
||||
Some(a) if a == i => previous.and_then(|p| self.docs.iter().position(|d| d.uid == p)).or_else(|| self.docs.len().checked_sub(1)),
|
||||
Some(a) if a > i => Some(a - 1),
|
||||
a => a,
|
||||
};
|
||||
Some(uid)
|
||||
}
|
||||
|
||||
/// Create in the New Document window, with the settings the preview shows: the preview
|
||||
/// document becomes the new document, as `file.new` would make it (the next Untitled number,
|
||||
/// no undo history). Returns its index, or `None` when there is no unedited preview to keep.
|
||||
pub fn keep_new_document_preview(&mut self) -> Option<usize> {
|
||||
let (uid, _) = self.new_document_preview?;
|
||||
let i = self.docs.iter().position(|d| d.uid == uid && !d.is_dirty() && d.path.is_none())?;
|
||||
self.new_document_preview = None;
|
||||
self.untitled += 1;
|
||||
if let Some(d) = self.docs.get_mut(i) {
|
||||
d.history.undo.clear();
|
||||
d.history.redo.clear();
|
||||
}
|
||||
self.active = Some(i);
|
||||
Some(i)
|
||||
}
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("file.savePreset", "missing `name`"))?.to_string();
|
||||
if name.chars().count() > MAX_PRESET_NAME {
|
||||
@@ -1168,6 +1253,45 @@ mod new_document_tests {
|
||||
assert_eq!(d.settings.bleed, [0.0; 4]);
|
||||
assert_eq!(d.settings.slug, [0.0, 1296.0, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_preview_replaces_itself_and_leaves_the_session_as_it_was() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-new-doc-preview-{}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
s.recovery_dir = Some(dir.clone());
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let first = s.doc().unwrap().uid;
|
||||
let a = s.preview_new_document(&json!({"width": 300})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (a, "Untitled-2"));
|
||||
let b = s.preview_new_document(&json!({"width": 400, "columns": 3})).unwrap();
|
||||
assert_eq!(s.documents().len(), 2, "the preview replaces itself");
|
||||
assert!(s.preview_new_document(&json!({"width": 0})).is_err());
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((d.uid, d.doc.settings.page_width, d.doc.page(0).unwrap().columns.count), (b, 400.0, 3), "a refused size keeps it");
|
||||
let listed = s.execute("document.list", &json!({})).unwrap();
|
||||
assert_eq!((listed[0].get("preview"), &listed[1]["preview"]), (None, &json!(true)));
|
||||
assert_eq!(s.end_new_document_preview(), Some(b));
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((s.documents().len(), d.uid, d.is_dirty(), d.history.undo.len()), (1, first, false, 0));
|
||||
assert_eq!(s.end_new_document_preview(), None, "without a preview it does nothing");
|
||||
// Edited by a script while shown, it writes no recovery data while it is a preview, and
|
||||
// stays as a document when the window closes, with its Untitled number.
|
||||
let c = s.preview_new_document(&json!({})).unwrap();
|
||||
s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
|
||||
assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0);
|
||||
assert_eq!(s.end_new_document_preview(), None);
|
||||
assert_eq!((s.documents().len(), s.doc().unwrap().uid, s.doc().unwrap().doc.title.as_str()), (2, c, "Untitled-2"));
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.title, "Untitled-3");
|
||||
// Create with the previewed settings keeps the preview as the document.
|
||||
let d = s.preview_new_document(&json!({"pages": 2})).unwrap();
|
||||
assert_eq!(s.keep_new_document_preview(), Some(3));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.uid, st.doc.title.as_str(), s.new_document_preview()), (d, "Untitled-4", None));
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.title, "Untitled-5");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,7 +12,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(query "app.links", "Community & Project Links", [], None,
|
||||
"{} → {discord, website, appPage, github, issues} (ArtCraft Discord, website, DesignCraft page and repository)", always, |_, _| Ok(crate::links::all())),
|
||||
cmd!(query "document.list", "List Documents", [], None, "{}", always, |s, _| {
|
||||
Ok(json!(s.documents().iter().enumerate().map(|(i, d)| json!({"index": i, "title": d.title(), "dirty": d.is_dirty(), "active": Some(i) == s.active_index()})).collect::<Vec<_>>()))
|
||||
let preview = s.new_document_preview();
|
||||
Ok(json!(s
|
||||
.documents()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
let mut v = json!({"index": i, "title": d.title(), "dirty": d.is_dirty(), "active": Some(i) == s.active_index()});
|
||||
// New Document's preview, not a document yet.
|
||||
if preview == Some(d.uid) {
|
||||
v["preview"] = json!(true);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect::<Vec<_>>()))
|
||||
}),
|
||||
cmd!(noundo "tool.select", "Select Tool", [], None, "{tool: selection|directSelection|type|rectangleFrame|…}", always, |s, p| {
|
||||
let t = p.get("tool").and_then(Value::as_str).unwrap_or("selection");
|
||||
|
||||
@@ -327,6 +327,9 @@ pub struct Session {
|
||||
pub book: Option<cmd::book::Book>,
|
||||
/// Content Collector conveyor: collected objects as snippets (name, bytes).
|
||||
pub conveyor: Vec<(String, Vec<u8>)>,
|
||||
/// New Document's Preview: the preview document's uid and the uid of the document that was
|
||||
/// active before it (see [`Session::preview_new_document`]).
|
||||
pub(crate) new_document_preview: Option<(u64, Option<u64>)>,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -364,6 +367,7 @@ impl Session {
|
||||
conveyor: Vec::new(),
|
||||
book: None,
|
||||
untitled: 0,
|
||||
new_document_preview: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -403,7 +403,8 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
||||
prepare_save(s, dir)?;
|
||||
let mut n = 0;
|
||||
for d in &s.docs {
|
||||
if !d.is_dirty() {
|
||||
// New Document's preview is not a document until Create makes it one.
|
||||
if !d.is_dirty() || s.new_document_preview() == Some(d.uid) {
|
||||
discard(dir, d.uid);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,18 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value {
|
||||
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
|
||||
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"window": [r.width(), r.height()],
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
|
||||
"documents": app
|
||||
.session
|
||||
.documents()
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let mut v = json!({"title": d.title(), "dirty": d.is_dirty()});
|
||||
if app.session.new_document_preview() == Some(d.uid) {
|
||||
v["preview"] = json!(true);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
"currentPage": crate::canvas::current_page(app),
|
||||
"dialog": app.ui.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()),
|
||||
"activeDocument": app.session.active_index(),
|
||||
|
||||
@@ -2096,6 +2096,14 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
if cancel || (!popup_open && ui.input(|i| i.key_pressed(egui::Key::Escape))) {
|
||||
result = Some(false);
|
||||
}
|
||||
if d.id == "newDocument" {
|
||||
ui.add_space(8.0);
|
||||
check(ui, &mut d, "preview", crate::i18n::tr(&app.ui.language, "Preview"));
|
||||
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
|
||||
ui.add_space(8.0);
|
||||
crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
}
|
||||
if d.id == "closeDocument" && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Don't Save"))).clicked() {
|
||||
d.fields.insert("discard".into(), json!(true));
|
||||
result = Some(true);
|
||||
@@ -2109,6 +2117,9 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
{
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
}
|
||||
if d.id == "newDocument" && result.is_none() {
|
||||
crate::new_document::preview(app, &mut d, ctx);
|
||||
}
|
||||
app.ui.dialog = Some(d);
|
||||
match result {
|
||||
Some(true) => {
|
||||
@@ -2228,6 +2239,7 @@ fn confirm_cell_options(app: &mut DesignApp, d: &Dialog) -> Result<Value, String
|
||||
/// opened with, since choosing one previews it at once.
|
||||
pub fn cancel(app: &mut DesignApp) {
|
||||
let d = app.ui.dialog.take();
|
||||
crate::new_document::end_preview(app);
|
||||
if d.as_ref().is_some_and(|d| d.fields.contains_key(PREVIEWED)) {
|
||||
app.session.cancel_interaction();
|
||||
}
|
||||
@@ -2248,14 +2260,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
app.cancel_pdf_import();
|
||||
}
|
||||
match d.id.as_str() {
|
||||
"newDocument" => {
|
||||
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
|
||||
}
|
||||
"newDocument" => crate::new_document::create(app, d),
|
||||
"frameSize" => {
|
||||
let x = d.fields.get("x").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let y = d.fields.get("y").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
|
||||
@@ -260,6 +260,9 @@ pub struct UiState {
|
||||
/// The font menus' sample text (a key of `panels::FONT_SAMPLES`) and size (0–2).
|
||||
pub font_sample: String,
|
||||
pub font_preview_size: u8,
|
||||
/// File › New › Document › Preview, kept for the session.
|
||||
#[serde(skip)]
|
||||
pub new_document_preview: bool,
|
||||
/// Transform reference point (0..8, row-major; 0 = top-left).
|
||||
pub ref_point: u8,
|
||||
/// Align To target (`selection`, `keyObject`, `margins`, `page`, `spread`).
|
||||
@@ -371,6 +374,7 @@ impl Default for UiState {
|
||||
recent_new_documents: Vec::new(),
|
||||
font_sample: "selected".into(),
|
||||
font_preview_size: 1,
|
||||
new_document_preview: false,
|
||||
ref_point: 0,
|
||||
align_to: "selection".into(),
|
||||
text_style_tab: 0,
|
||||
@@ -1061,6 +1065,7 @@ impl DesignApp {
|
||||
}
|
||||
self.last_time = now;
|
||||
self.drain_control(ctx);
|
||||
new_document::end_stale_preview(self);
|
||||
if self.fonts_ready {
|
||||
self.drain_inbox();
|
||||
self.open_os_documents();
|
||||
|
||||
@@ -139,6 +139,150 @@ pub(crate) fn remember(app: &mut DesignApp, p: &Value) {
|
||||
recent.truncate(MAX_RECENT);
|
||||
}
|
||||
|
||||
/// The `file.new` params of the document Preview shows (set while it is shown).
|
||||
const PREVIEWED: &str = "_previewedDoc";
|
||||
/// The params Preview last tried, made or refused (refused ones are not tried again).
|
||||
const TRIED: &str = "_previewTried";
|
||||
/// The params Create was refused with: the reason shows until a field changes.
|
||||
const REFUSED: &str = "_refusedParams";
|
||||
/// Settings changed since the preview was made, and when they last changed (egui seconds).
|
||||
const PENDING: &str = "_previewPending";
|
||||
/// How long the settings stay unchanged before the preview is made again.
|
||||
const SETTLE_SECONDS: f64 = 0.2;
|
||||
|
||||
/// The text of the first field that doesn't hold a value yet (empty, half typed, negative).
|
||||
fn invalid_field(d: &Dialog) -> Option<String> {
|
||||
let bad_measure = measure_keys().into_iter().find(|k| d.fields.contains_key(k) && !measure(d, k).is_some_and(|v| v >= 0.0));
|
||||
let bad_count = || {
|
||||
["pages", "startPage", "columns"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.find(|k| d.fields.contains_key(k) && !d.n(k).is_some_and(|v| v.is_finite() && v >= 1.0))
|
||||
};
|
||||
bad_measure.or_else(bad_count).map(|k| d.s(&k))
|
||||
}
|
||||
|
||||
/// `file.new` params for Preview and Create, or `None` while a field doesn't hold a value yet.
|
||||
fn preview_params(d: &Dialog) -> Option<Value> {
|
||||
invalid_field(d).is_none().then(|| params(d))
|
||||
}
|
||||
|
||||
/// Preview on: a document with the dialog's settings shows in the workspace. It is made at once,
|
||||
/// then again once changed settings have stayed the same for [`SETTLE_SECONDS`]. Settings that
|
||||
/// aren't values yet, or that `file.new` refuses (a zero width), keep the last one. Off: it goes.
|
||||
pub(crate) fn preview(app: &mut DesignApp, d: &mut Dialog, ctx: &egui::Context) {
|
||||
if d.fields.get(REFUSED).is_some_and(|r| *r != params(d)) {
|
||||
d.fields.remove(REFUSED);
|
||||
d.fields.remove("status");
|
||||
}
|
||||
let on = match d.fields.get("preview").and_then(Value::as_bool) {
|
||||
Some(on) => on,
|
||||
None => {
|
||||
d.fields.insert("preview".into(), json!(app.ui.new_document_preview));
|
||||
app.ui.new_document_preview
|
||||
}
|
||||
};
|
||||
app.ui.new_document_preview = on;
|
||||
if !on {
|
||||
end_preview(app);
|
||||
for k in [PREVIEWED, TRIED, PENDING] {
|
||||
d.fields.remove(k);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(p) = preview_params(d) else { return };
|
||||
// Refused settings are not tried again until they change, and settings back to the shown
|
||||
// ones keep that preview.
|
||||
if d.fields.get(PREVIEWED) == Some(&p) && app.session.new_document_preview().is_some() {
|
||||
d.fields.insert(TRIED.into(), p.clone());
|
||||
}
|
||||
if d.fields.get(TRIED) == Some(&p) {
|
||||
d.fields.remove(PENDING);
|
||||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if d.fields.contains_key(TRIED) {
|
||||
let since = match d.fields.get(PENDING) {
|
||||
Some(pending) if pending.get("params") == Some(&p) => pending.get("since").and_then(Value::as_f64).unwrap_or(now),
|
||||
_ => {
|
||||
d.fields.insert(PENDING.into(), json!({"params": p, "since": now}));
|
||||
now
|
||||
}
|
||||
};
|
||||
let wait = SETTLE_SECONDS - (now - since);
|
||||
if wait > 0.0 {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let shown = app.session.new_document_preview();
|
||||
if app.session.preview_new_document(&p).is_ok() {
|
||||
forget_view(app, shown);
|
||||
d.fields.insert(PREVIEWED.into(), p.clone());
|
||||
}
|
||||
d.fields.remove(PENDING);
|
||||
d.fields.insert(TRIED.into(), p);
|
||||
}
|
||||
|
||||
/// Create: refused while a field doesn't hold a value yet. With the settings the preview shows,
|
||||
/// the preview document becomes the new document; otherwise `file.new` makes it and the preview
|
||||
/// goes. A refusal leaves the window and the preview open, with the reason.
|
||||
pub(crate) fn create(app: &mut DesignApp, mut d: Dialog) -> Result<Value, String> {
|
||||
let refused = |app: &mut DesignApp, mut d: Dialog, e: String| {
|
||||
app.status(e.clone());
|
||||
d.fields.insert("status".into(), json!(e));
|
||||
d.fields.insert(REFUSED.into(), params(&d));
|
||||
app.ui.dialog = Some(d);
|
||||
Err(e)
|
||||
};
|
||||
if let Some(text) = invalid_field(&d) {
|
||||
let e = format!("{}: {}", crate::i18n::tr(&app.ui.language, "Invalid value"), text.trim());
|
||||
return refused(app, d, e);
|
||||
}
|
||||
let p = params(&d);
|
||||
let shows = d.fields.get(PREVIEWED) == Some(&p) && app.session.new_document_preview().is_some();
|
||||
let kept = if d.b("preview") && shows { app.session.keep_new_document_preview() } else { None };
|
||||
let r = match kept {
|
||||
Some(i) => Ok(json!({"index": i})),
|
||||
None => app.run("file.new", p.clone()),
|
||||
};
|
||||
match r {
|
||||
Ok(v) => {
|
||||
// The new document is active, so the preview's document goes without coming back.
|
||||
end_preview(app);
|
||||
remember(app, &p);
|
||||
Ok(v)
|
||||
}
|
||||
Err(e) => {
|
||||
d.fields.remove(PENDING);
|
||||
refused(app, d, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the preview document, if one is shown. Without a preview it does nothing.
|
||||
pub(crate) fn end_preview(app: &mut DesignApp) {
|
||||
let uid = app.session.end_new_document_preview();
|
||||
forget_view(app, uid);
|
||||
}
|
||||
|
||||
/// End a preview whose New Document window has gone or no longer previews (closed over the
|
||||
/// control channel, or replaced by another dialog).
|
||||
pub(crate) fn end_stale_preview(app: &mut DesignApp) {
|
||||
let previewing = app.ui.dialog.as_ref().is_some_and(|d| d.id == "newDocument" && d.b("preview"));
|
||||
if !previewing && app.session.new_document_preview().is_some() {
|
||||
end_preview(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the views (one per pane) of a preview document that has gone.
|
||||
fn forget_view(app: &mut DesignApp, uid: Option<u64>) {
|
||||
if let Some(uid) = uid.filter(|uid| app.session.documents().iter().all(|d| d.uid != *uid)) {
|
||||
app.views.remove(&uid);
|
||||
app.views.remove(&(uid ^ (1 << 63)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the details as the document preset `name` (`file.savePreset`, which replaces one of that
|
||||
/// name) and show it on the Saved tab.
|
||||
fn save_preset(app: &mut DesignApp, d: &mut Dialog, name: &str) -> Result<(), String> {
|
||||
@@ -743,9 +887,14 @@ mod tests {
|
||||
|
||||
/// 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)> {
|
||||
frame_after(app, ctx, events, 1.0 / 60.0)
|
||||
}
|
||||
|
||||
/// One frame `dt` seconds after the last.
|
||||
fn frame_after(app: &mut DesignApp, ctx: &egui::Context, events: Vec<egui::Event>, dt: f64) -> 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),
|
||||
time: Some(ctx.input(|i| i.time) + dt),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
@@ -894,6 +1043,161 @@ mod tests {
|
||||
assert!(h.state().app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// Set dialog fields, then let the preview settle.
|
||||
fn set(app: &mut DesignApp, ctx: &egui::Context, fields: &[(&str, Value)]) {
|
||||
let d = app.ui.dialog.as_mut().unwrap();
|
||||
for (k, v) in fields {
|
||||
d.fields.insert((*k).into(), v.clone());
|
||||
}
|
||||
frame(app, ctx, vec![]);
|
||||
frame_after(app, ctx, vec![], 2.0 * SETTLE_SECONDS);
|
||||
}
|
||||
|
||||
fn shown(app: &DesignApp) -> (usize, f64, u32) {
|
||||
let doc = &app.session.active().unwrap().doc;
|
||||
(app.session.documents().len(), doc.settings.page_width, doc.page(0).unwrap().columns.count)
|
||||
}
|
||||
|
||||
fn escape(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
frame(
|
||||
app,
|
||||
ctx,
|
||||
vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }],
|
||||
);
|
||||
assert!(app.ui.dialog.is_none());
|
||||
}
|
||||
|
||||
/// Documents A and B with A active, and the New Document window open over them.
|
||||
fn two_documents() -> (DesignApp, egui::Context, designcraft_engine::DocState) {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"title": "A"})).unwrap();
|
||||
app.run("file.new", json!({"title": "B"})).unwrap();
|
||||
app.session.set_active(0);
|
||||
let a = app.session.active().unwrap().clone();
|
||||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||||
(app, context(), a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_shows_the_settings_live_and_close_restores_the_workspace() {
|
||||
let (mut app, ctx, a) = two_documents();
|
||||
settle(&mut app, &ctx);
|
||||
assert_eq!(app.session.documents().len(), 2, "Preview starts off");
|
||||
set(&mut app, &ctx, &[("preview", json!(true))]);
|
||||
assert_eq!(shown(&app), (3, 612.0, 1), "a third tab shows the dialog's settings");
|
||||
let uid = app.session.new_document_preview();
|
||||
settle(&mut app, &ctx);
|
||||
assert_eq!(app.session.new_document_preview(), uid, "unchanged settings keep the same preview");
|
||||
// A change waits for the typing to stop.
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("width".into(), json!("300 pt"));
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(shown(&app).1, 612.0);
|
||||
set(&mut app, &ctx, &[("columns", json!("3"))]);
|
||||
assert_eq!(shown(&app), (3, 300.0, 3), "and then follows them");
|
||||
// Half-typed and refused values keep the last preview.
|
||||
for width in ["", "-5", "0"] {
|
||||
set(&mut app, &ctx, &[("width", json!(width))]);
|
||||
assert_eq!(shown(&app), (3, 300.0, 3), "{width:?}");
|
||||
}
|
||||
// Escape closes the window: the preview goes and A is shown again, untouched.
|
||||
escape(&mut app, &ctx);
|
||||
let back = app.session.active().unwrap();
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!((back.uid, back.revision, back.history.undo.len(), back.is_dirty()), (a.uid, a.revision, 0, false));
|
||||
// Preview is remembered; turning it off removes the preview.
|
||||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
set(&mut app, &ctx, &[("preview", json!(false))]);
|
||||
assert_eq!((app.session.documents().len(), app.session.active().unwrap().uid), (2, a.uid));
|
||||
// `ui.dialog.cancel` removes it too.
|
||||
set(&mut app, &ctx, &[("preview", json!(true))]);
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
let (req, _) = crate::control::ControlRequest::new("ui.dialog.cancel", json!({}));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::control::handle(&mut app, ui.ctx(), &req);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert_eq!((app.session.documents().len(), app.session.active().unwrap().uid), (2, a.uid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_update_loop_ends_a_preview_whose_window_has_gone() {
|
||||
let (mut app, ctx, a) = two_documents();
|
||||
set(&mut app, &ctx, &[("preview", json!(true))]);
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
app.ui.dialog = None;
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
assert_eq!((app.session.documents().len(), app.session.active().unwrap().uid), (2, a.uid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_leaves_a_document_activated_or_edited_meanwhile() {
|
||||
let (mut app, ctx, _) = two_documents();
|
||||
set(&mut app, &ctx, &[("preview", json!(true))]);
|
||||
// A file opened (here: made) by a script while the window is open stays active.
|
||||
app.run("file.new", json!({"title": "Opened"})).unwrap();
|
||||
escape(&mut app, &ctx);
|
||||
let titles: Vec<String> = app.session.documents().iter().map(|d| d.title()).collect();
|
||||
assert_eq!((titles, app.session.active().unwrap().title()), (vec!["A".into(), "B".into(), "Opened".into()], "Opened".into()));
|
||||
// A preview edited by a menu or script is kept as a document, with its Untitled number.
|
||||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let preview = app.session.new_document_preview();
|
||||
app.run("frame.create", json!({"rect": [10, 10, 100, 100]})).unwrap();
|
||||
escape(&mut app, &ctx);
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((app.session.documents().len(), Some(st.uid), st.is_dirty()), (4, preview, true));
|
||||
assert_ne!(st.doc.title, app.session.next_untitled_title());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_start_screen_preview_leaves_no_document() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
set(&mut app, &ctx, &[("preview", json!(true))]);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
escape(&mut app, &ctx);
|
||||
assert!(app.session.documents().is_empty() && app.session.active().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_keeps_the_previewed_document_with_the_final_settings() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"title": "A"})).unwrap();
|
||||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||||
let ctx = context();
|
||||
set(&mut app, &ctx, &[("preview", json!(true)), ("width", json!("400 pt"))]);
|
||||
set(&mut app, &ctx, &[("pages", json!("4")), ("facingPages", json!(false))]);
|
||||
let preview = app.session.new_document_preview();
|
||||
// An invalid field refuses Create: the window and the preview stay.
|
||||
set(&mut app, &ctx, &[("pages", json!(""))]);
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
assert!(app.ui.dialog.as_ref().unwrap().s("status").contains("Invalid value"));
|
||||
assert_eq!((app.session.documents().len(), app.session.new_document_preview()), (2, preview));
|
||||
// A size file.new refuses also keeps them.
|
||||
set(&mut app, &ctx, &[("pages", json!("4")), ("width", json!("0"))]);
|
||||
assert!(crate::dialogs::confirm(&mut app).is_err());
|
||||
assert_eq!((app.session.documents().len(), app.session.new_document_preview()), (2, preview));
|
||||
// With the previewed settings, the preview becomes the document.
|
||||
set(&mut app, &ctx, &[("width", json!("400 pt"))]);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((app.session.documents().len(), st.doc.settings.page_width, st.doc.page_count()), (2, 400.0, 4));
|
||||
assert!(!st.doc.settings.facing_pages && app.ui.dialog.is_none());
|
||||
assert_eq!((Some(st.uid), st.doc.title.as_str(), st.history.undo.len(), st.is_dirty()), (preview, "Untitled-2", 0, false));
|
||||
assert_eq!(app.session.new_document_preview(), None);
|
||||
// Settings changed just before Create (the preview not made yet) make a new document.
|
||||
app.run("app.newDocumentDialog", json!({})).unwrap();
|
||||
set(&mut app, &ctx, &[]);
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("pages".into(), json!("2"));
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!((app.session.documents().len(), st.doc.page_count(), st.doc.title.as_str()), (3, 2, "Untitled-3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_are_saved_by_name_and_recent_documents_once() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -434,7 +434,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- 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) beside the preset's details: 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.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`.
|
||||
- New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: 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.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it.
|
||||
- Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included.
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user