mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user