feat(ui): New Document Preview follows side paths and keeps Create in step

- A preview that a menu or script edits or saves while the window is open
  becomes a document of its own: Close keeps it and gives it its Untitled
  number. document.list and the control inspect mark the preview.
- Close shows the previously active document again only if the preview was
  still active; a document opened meanwhile stays active.
- Create refuses invalid fields with a status beside the buttons, keeping
  the window and the preview. When the settings are the previewed ones, the
  preview document becomes the new document; otherwise file.new makes it,
  and a failure puts the window back with the error.
- The preview is rebuilt once the fields have stayed the same for 200 ms,
  and refused settings are not retried until they change.
- No recovery discard for preview documents, which are never written.
- The window's key is _previewedDoc, distinct from the interaction preview
  key, and ending the preview is a no-op without one.
This commit is contained in:
aiongg
2026-10-13 04:06:10 +00:00
parent 5da112a9cb
commit 7fdca7387b
6 changed files with 299 additions and 76 deletions
+1 -1
View File
@@ -129,7 +129,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 as they change. Create keeps it with a clean undo history; Close removes it and shows the previous document again. The preview takes no Untitled number and writes no recovery data. No readiness percentage change.
- **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-12** · Window close and application/menu Quit ask about unsaved documents; later Cancel keeps every tab open, including earlier Don't Save choices. Approvals are rechecked after edits. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
+61 -26
View File
@@ -172,23 +172,23 @@ impl Session {
/// 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)?;
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;
let shown = self.new_document_preview.and_then(|(old, _)| self.docs.iter().position(|d| d.uid == old));
let previous = match self.new_document_preview {
Some((_, previous)) => previous,
None => self.active().map(|d| d.uid),
};
match shown.and_then(|i| self.docs.get_mut(i).map(|d| (i, d))) {
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)) => {
let old = std::mem::replace(d, st);
#[cfg(not(target_arch = "wasm32"))]
if let Some(dir) = &self.recovery_dir {
crate::recovery::discard(dir, old.uid);
}
#[cfg(target_arch = "wasm32")]
let _ = old;
*d = st;
self.active = Some(i);
}
None => {
@@ -206,21 +206,42 @@ impl Session {
self.new_document_preview.map(|(uid, _)| uid)
}
/// Remove New Document's preview document and make the document that was active before it
/// active again. Returns the preview's 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()?;
if let Some(i) = self.docs.iter().position(|d| d.uid == uid) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(dir) = &self.recovery_dir {
crate::recovery::discard(dir, uid);
}
self.docs.remove(i);
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;
}
let back = previous.and_then(|p| self.docs.iter().position(|d| d.uid == p));
self.active = back.or_else(|| self.docs.len().checked_sub(1));
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> {
@@ -881,14 +902,28 @@ mod new_document_tests {
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");
// Even edited, it never reaches the recovery folder.
s.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
assert_eq!(crate::recovery::save(&s, &dir).unwrap(), 0);
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-2", "the preview took no Untitled number");
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);
}
}
+14 -1
View File
@@ -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");
+12 -1
View File
@@ -76,7 +76,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(),
+6 -11
View File
@@ -1660,6 +1660,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
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));
@@ -1669,7 +1673,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
});
});
if d.id == "newDocument" && result.is_none() {
crate::new_document::preview(app, &mut d);
crate::new_document::preview(app, &mut d, ctx);
}
app.ui.dialog = Some(d);
match result {
@@ -1692,16 +1696,7 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
app.cancel_pdf_import();
}
match d.id.as_str() {
"newDocument" => {
// The preview goes; Create makes the document afresh, with no undo history.
crate::new_document::end_preview(app);
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);
+205 -36
View File
@@ -140,20 +140,41 @@ pub(crate) fn remember(app: &mut DesignApp, p: &Value) {
}
/// The `file.new` params of the document Preview shows (set while it is shown).
const PREVIEWED: &str = "_previewed";
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;
/// `file.new` params for Preview, or `None` while a field doesn't hold a value yet (empty, half
/// typed, negative): the preview then stays as it was.
fn preview_params(d: &Dialog) -> Option<Value> {
let measures = measure_keys().into_iter().all(|k| !d.fields.contains_key(&k) || measure(d, &k).is_some_and(|v| v >= 0.0));
let counts =
["pages", "startPage", "columns"].into_iter().all(|k| !d.fields.contains_key(k) || d.n(k).is_some_and(|v| v.is_finite() && v >= 1.0));
(measures && counts).then(|| params(d))
/// 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))
}
/// Preview on: a document with the dialog's settings shows in the workspace, made again when a
/// setting changes. Settings `file.new` refuses (a zero width) keep the last one. Off: it goes.
pub(crate) fn preview(app: &mut DesignApp, d: &mut Dialog) {
/// `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 => {
@@ -164,22 +185,82 @@ pub(crate) fn preview(app: &mut DesignApp, d: &mut Dialog) {
app.ui.new_document_preview = on;
if !on {
end_preview(app);
d.fields.remove(PREVIEWED);
for k in [PREVIEWED, TRIED, PENDING] {
d.fields.remove(k);
}
return;
}
let Some(p) = preview_params(d) else { return };
let shown = app.session.new_document_preview();
if shown.is_some() && d.fields.get(PREVIEWED) == Some(&p) {
// 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());
}
// Refused settings are not tried again until they change.
d.fields.insert(PREVIEWED.into(), p);
d.fields.remove(PENDING);
d.fields.insert(TRIED.into(), p);
}
/// Remove the preview document, if one is shown, and show the document that was active before.
/// 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);
@@ -196,7 +277,7 @@ pub(crate) fn end_stale_preview(app: &mut DesignApp) {
/// 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 {
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)));
}
@@ -806,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()
};
@@ -957,12 +1043,14 @@ 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) {
@@ -970,49 +1058,110 @@ mod tests {
(app.session.documents().len(), doc.settings.page_width, doc.page(0).unwrap().columns.count)
}
#[test]
fn preview_shows_the_settings_live_and_close_restores_the_workspace() {
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();
let ctx = context();
(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");
set(&mut app, &ctx, &[("width", json!("300 pt")), ("columns", json!("3"))]);
assert_eq!(shown(&app), (3, 300.0, 3), "and follows them");
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.
frame(
&mut 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());
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, as does closing the window
// any other way.
// 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))]);
app.ui.dialog = None;
end_stale_preview(&mut app);
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());
@@ -1021,12 +1170,32 @@ mod tests {
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);
assert_eq!((st.doc.title.as_str(), st.history.undo.len(), st.is_dirty()), ("Untitled-2", 0, false));
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]