diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..8b6ea2d7d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (orderly native exit recovery) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Pages panel parents) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Pages panel: double-clicking a parent shows that parent and selects only its row; parent rows show rendered thumbnails. Commands name parents by id, full name, or a prefix only one parent has; an unknown or ambiguous name is an error instead of clearing the page's parent (#624). No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..aa08dd65d 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -82,14 +82,14 @@ pub fn specs() -> Vec { "Insert Pages…", ["Layout", "Pages"], Some("Cmd+Shift+P"), - "{count?: 1, after?: page index (default: last), parent?: \"A\"|null}", + "{count?: 1, after?: page index (default: last), parent?: id|\"A-Parent\"|\"A\"|null}", has_doc, |s, p| { let count = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9999) as usize; let st = s.doc()?; let n = st.doc.page_count(); let after = p.get("after").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(n - 1); - let parent = parent_ref(&st.doc, p.get("parent")).unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); + let parent = parent_ref("layout.pages.insert", &st.doc, p.get("parent"))?.unwrap_or_else(|| st.doc.parents.first().map(|x| x.id)); s.edit(|d, _| { let ids = d.insert_pages(Some(after), count, parent)?; Ok(json!({"pages": ids.len(), "total": d.page_count()})) @@ -155,7 +155,7 @@ pub fn specs() -> Vec { "Apply Parent to Pages…", ["Layout", "Pages"], None, - "{pages: [index], parent: \"A\"|null}", + "{pages: [index], parent: id|\"A-Parent\"|\"A\"|null (null or \"[None]\" clears)}", has_doc, |s, p| { let pages: Vec = p @@ -163,7 +163,7 @@ pub fn specs() -> Vec { .and_then(Value::as_array) .map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()) .unwrap_or_default(); - let parent = parent_ref(&s.doc()?.doc, p.get("parent")).unwrap_or(None); + let parent = parent_ref("layout.pages.applyParent", &s.doc()?.doc, p.get("parent"))?.unwrap_or(None); s.edit(|d, _| { d.apply_parent(&pages, parent)?; ok() @@ -179,13 +179,42 @@ pub fn specs() -> Vec { Ok(json!({"id": id.0, "prefix": prefix})) }) }), - cmd!(noundo "layout.parents.edit", "Edit Parents", [], None, "{on: bool} — show parent spreads on the canvas", has_doc, |s, p| { - let st = s.doc_mut()?; - st.editing_parents = p.get("on").and_then(Value::as_bool).unwrap_or(!st.editing_parents); - st.selection = Default::default(); - st.revision += 1; - ok() - }), + cmd!( + noundo "layout.parents.edit", + "Edit Parents", + [], + None, + "{on?: bool, parent?: id|\"A-Parent\"|\"A\"} — show parent spreads on the canvas; `parent` shows that parent (and implies on). Turning on without `parent` shows the first parent", + has_doc, + |s, p| { + const ID: &str = "layout.parents.edit"; + let doc = s.doc()?.doc.clone(); + let parent = match p.get("parent") { + None | Some(Value::Null) => None, + v => match parent_ref(ID, &doc, v)? { + Some(Some(id)) => Some(id), + _ => return Err(bad(ID, "`parent` names a parent to show")), + }, + }; + let st = s.doc_mut()?; + let was = st.editing_parents; + let on = p.get("on").and_then(Value::as_bool).unwrap_or(parent.is_some() || !was); + st.editing_parents = on; + st.selection = Default::default(); + st.revision += 1; + // The parent to show: the one asked for, or the first when editing starts. + let index = match parent { + Some(id) => doc.parent_index(id), + None if !was && !doc.parents.is_empty() => Some(0), + None => None, + }; + let shown = index.filter(|_| on); + if let Some(i) = shown { + s.ui_requests.push(crate::UiRequest::View { params: json!({"showSpread": designcraft_doc::SpreadRef::Parent(i)}) }); + } + Ok(json!({"on": on, "parent": shown.and_then(|i| doc.parents.get(i)).map(|x| x.id.0)})) + } + ), cmd!( "layout.marginsAndColumns", "Margins and Columns…", @@ -609,13 +638,34 @@ pub fn specs() -> Vec { ] } -/// `"A"` → that parent's id; `null` → Some(None) (= [None]); missing → None (use default). -fn parent_ref(d: &designcraft_doc::Document, v: Option<&Value>) -> Option> { - match v? { - Value::Null => Some(None), - Value::String(s) if s == "[None]" => Some(None), - Value::String(s) => Some(d.parents.iter().find(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)).map(|p| p.id)), - _ => None, +/// A parent named by a command parameter: its id (a number), its full label (`"A-Parent"`), or +/// its prefix (`"A"`) when only one parent has it. `null` or `"[None]"` → `Some(None)` (no parent); +/// missing → `None` (the command's default). Unknown or ambiguous names are errors, so a typo never +/// clears a page's parent. +fn parent_ref(cmd: &str, d: &designcraft_doc::Document, v: Option<&Value>) -> Result>> { + let Some(v) = v else { return Ok(None) }; + match v { + Value::Null => Ok(Some(None)), + Value::String(s) if s == "[None]" => Ok(Some(None)), + Value::Number(n) => { + let id = n.as_u64().ok_or_else(|| bad(cmd, format!("no parent with id {n}")))?; + d.parents.iter().find(|p| p.id.0 == id).map(|p| Some(Some(p.id))).ok_or_else(|| bad(cmd, format!("no parent with id {id}"))) + } + Value::String(s) => { + let by = |f: &dyn Fn(&designcraft_doc::ParentInfo) -> bool| -> Vec { + d.parents.iter().filter(|p| p.parent.as_ref().is_some_and(f)).map(|p| p.id).collect() + }; + let mut hits = by(&|i| i.label() == *s); + if hits.is_empty() { + hits = by(&|i| i.prefix == *s); + } + match hits.as_slice() { + [id] => Ok(Some(Some(*id))), + [] => Err(bad(cmd, format!("no parent named `{s}`"))), + _ => Err(bad(cmd, format!("more than one parent is named `{s}`: give its id"))), + } + } + _ => Err(bad(cmd, "`parent` is a parent id, a parent name or null")), } } @@ -921,3 +971,61 @@ mod adjust_tests { assert_eq!(b, b2); } } + +#[cfg(test)] +mod parent_tests { + use serde_json::json; + + fn parent_of(s: &crate::Session, page: usize) -> Option { + s.doc().unwrap().doc.page(page).unwrap().parent.map(|p| p.0) + } + + #[test] + fn parents_with_one_prefix_stay_distinct() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": 2})).unwrap(); + let a = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Parent"})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("layout.parents.new", &json!({"prefix": "IMG", "name": "Half"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); + assert_eq!(parent_of(&s, 0), Some(b)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Half"})).unwrap(); + assert_eq!(parent_of(&s, 1), Some(b)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG-Parent"})).unwrap(); + assert_eq!(parent_of(&s, 1), Some(a)); + // A prefix two parents share, or a name no parent has, is an error and changes nothing. + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "IMG"})).is_err()); + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "Nope"})).is_err()); + assert!(s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": 999_999})).is_err()); + assert_eq!(parent_of(&s, 1), Some(a)); + s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "[None]"})).unwrap(); + assert_eq!(parent_of(&s, 1), None); + } + + #[test] + fn edit_parents_shows_the_named_parent() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let ids: Vec = + ["B", "C", "D"].iter().map(|p| s.execute("layout.parents.new", &json!({"prefix": p})).unwrap()["id"].as_u64().unwrap()).collect(); + s.ui_requests.clear(); + let r = s.execute("layout.parents.edit", &json!({"parent": ids[2]})).unwrap(); + assert_eq!(r, json!({"on": true, "parent": ids[2]})); + assert!(s.doc().unwrap().editing_parents); + let n = s.doc().unwrap().doc.parent_index(designcraft_doc::SpreadId(ids[2])).unwrap(); + let shown: Vec<_> = s + .ui_requests + .iter() + .filter_map(|r| match r { + crate::UiRequest::View { params } => params.get("showSpread").cloned(), + _ => None, + }) + .collect(); + assert_eq!(shown, vec![json!({"kind": "parent", "index": n})]); + let r = s.execute("layout.parents.edit", &json!({"on": true, "parent": "C-Parent"})).unwrap(); + assert_eq!(r["parent"], ids[1]); + assert!(s.execute("layout.parents.edit", &json!({"parent": "Z-Parent"})).is_err()); + let r = s.execute("layout.parents.edit", &json!({"on": false})).unwrap(); + assert_eq!(r, json!({"on": false, "parent": null})); + assert!(!s.doc().unwrap().editing_parents); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4c7313e61..4b1394c88 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -114,14 +114,35 @@ pub fn fit(app: &mut DesignApp, rect: Rect, what: &str) { let Some(u) = sel.into_iter().reduce(|a, b| a.union(b)) else { return }; b = u.inflate(u.width().max(u.height()) * 0.05 + 1.0, u.width().max(u.height()) * 0.05 + 1.0); } + fit_bounds(app, rect, b); +} + +/// Zoom and scroll so canvas rectangle `b` fills the canvas `rect`. +fn fit_bounds(app: &mut DesignApp, rect: Rect, b: DRect) { let pad = 40.0; - let zoom = ((rect.width() as f64 - 2.0 * pad) / b.width()).min((rect.height() as f64 - 2.0 * pad) / b.height()).clamp(0.05, 40.0); + let zoom = + ((rect.width() as f64 - 2.0 * pad) / b.width().max(1e-6)).min((rect.height() as f64 - 2.0 * pad) / b.height().max(1e-6)).clamp(0.05, 40.0); let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom); if let Some(v) = app.view_mut() { *v = View { zoom, origin, fitted: true, rotation: v.rotation }; } } +/// Fit spread `r` (a document or parent spread on the canvas as it is laid out now) in the canvas. +/// Before the canvas has a size, the next frame fits whatever spread is current. +pub fn show_spread(app: &mut DesignApp, r: SpreadRef) { + let Some(st) = app.session.active() else { return }; + let Some(b) = CanvasLayout::new(&st.doc, st.editing_parents).slot(r).map(|s| s.bounds) else { return }; + match app.canvas_rect { + Some(rect) => fit_bounds(app, rect, b), + None => { + if let Some(v) = app.view_mut() { + v.fitted = false; + } + } + } +} + /// Index of the slot nearest the view centre. pub fn current_slot(app: &DesignApp, layout: &CanvasLayout) -> Option { let v = app.view()?; @@ -182,6 +203,9 @@ pub fn set_zoom(app: &mut DesignApp, z: f64) { } pub fn apply_view_request(app: &mut DesignApp, p: &Value) { + if let Some(r) = p.get("showSpread").and_then(|v| serde_json::from_value::(v.clone()).ok()) { + show_spread(app, r); + } // Power Zoom (Hand tool, Alt-press): zoom out to the spread, aim a view-sized rectangle, zoom // back in where it was released. if let Some(phase) = p.get("powerZoom").and_then(Value::as_str) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..dd012a6bb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -979,7 +979,14 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; let page = crate::canvas::current_page(app).unwrap_or(0); - let spread = if st.editing_parents { json!({"kind": "parent", "index": 0}) } else { json!(st.doc.page_loc(page).map_or(0, |l| l.0)) }; + let spread = if st.editing_parents { + // The parent spread the canvas shows. + let layout = designcraft_tools::CanvasLayout::new(&st.doc, true); + let shown = crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).map(|s| s.spread); + json!(shown.unwrap_or(designcraft_doc::SpreadRef::Parent(0))) + } else { + json!(st.doc.page_loc(page).map_or(0, |l| l.0)) + }; return Some(app.run("guide.deleteAll", json!({"spread": spread}))); } "app.deletePage" | "app.duplicateSpread" => { diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6bbad9066..dae2597ec 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -3,38 +3,61 @@ use std::collections::HashMap; use std::sync::Mutex; +use designcraft_doc::{SpreadId, SpreadRef}; use egui::{Color32, Sense, Stroke, StrokeKind, vec2}; use serde_json::json; use crate::theme::Tokens; use crate::{DesignApp, icons}; -type ThumbKey = (u64, u64, usize); +/// What a thumbnail shows: a document page (absolute index) or a whole parent spread. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum ThumbOf { + Page(usize), + Parent(SpreadId), +} + +type ThumbKey = (u64, u64, ThumbOf); static THUMBS: Mutex>> = Mutex::new(None); -fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option { +/// Widest parent thumbnail, in pixels (a spread of many pages stays a small texture). +const MAX_THUMB_W: f64 = 1024.0; + +fn thumb(app: &mut DesignApp, ctx: &egui::Context, of: ThumbOf, h: f32) -> Option { let st = app.session.active()?; - let key = (st.uid, st.revision, abs); + let key = (st.uid, st.revision, of); let mut g = THUMBS.lock().unwrap_or_else(|e| e.into_inner()); let map = g.get_or_insert_with(HashMap::new); if let Some(t) = map.get(&key) { return Some(t.clone()); } - let page = st.doc.page(abs)?; - let scale = (h as f64 * ctx.pixels_per_point() as f64) / page.height; + let px = h as f64 * ctx.pixels_per_point() as f64; let mut r = designcraft_render::Renderer::new(); r.threads = 0; - let img = r.render_page( - &st.doc, - &app.session.cache, - abs, - scale, - false, - &designcraft_render::RenderOptions { printing_only: true, ..Default::default() }, - )?; + let opts = designcraft_render::RenderOptions { printing_only: true, ..Default::default() }; + let (img, name) = match of { + ThumbOf::Page(abs) => { + let page = st.doc.page(abs)?; + let img = r.render_page(&st.doc, &app.session.cache, abs, px / page.height.max(1e-6), false, &opts)?; + (img, format!("thumb{abs}")) + } + ThumbOf::Parent(id) => { + let i = st.doc.parent_index(id)?; + let b = st.doc.parents.get(i)?.bounds(); + if !(b.width() > 0.0 && b.height() > 0.0) { + return None; + } + let scale = (px / b.height()).min(MAX_THUMB_W / b.width()); + let (w, h) = ((b.width() * scale).round().max(1.0) as u32, (b.height() * scale).round().max(1.0) as u32); + let view = designcraft_geom::Affine::scale(scale) * designcraft_geom::Affine::translate(-b.origin().to_vec2()); + let o = designcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..opts }; + let placed = [designcraft_render::Placed { spread: SpreadRef::Parent(i), xf: designcraft_geom::Affine::IDENTITY }]; + (r.render(&st.doc, &app.session.cache, &placed, w, h, view, &o), format!("thumbp{}", id.0)) + } + }; let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let tex = ctx.load_texture(format!("thumb{abs}"), ci, egui::TextureOptions::LINEAR); + let tex = ctx.load_texture(name, ci, egui::TextureOptions::LINEAR); // Drop thumbnails of older revisions. let (uid, rev) = (st.uid, st.revision); map.retain(|k, _| !(k.0 == uid && k.1 != rev)); @@ -47,8 +70,8 @@ fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option enum PageDrag { /// A document page (absolute index). Page(usize), - /// A parent (prefix; `None` = [None]). - Parent(Option), + /// A parent (`None` = [None]). + Parent(Option), } /// Selected-page tint (multiplied over the thumbnail). @@ -60,14 +83,41 @@ const THUMB_H: f32 = 37.0; /// Parent rows. const ROW_H: f32 = 29.0; -/// Paint a blank page icon of `n` pages (parents list), right-aligned at `right`. -fn blank_spread(p: &egui::Painter, right: egui::Pos2, n: usize, aspect: f32, t: &Tokens) { - let h = 24.0; - let w = h * aspect; - let n = n.max(1); - for k in 0..n { - let r = egui::Rect::from_min_size(egui::pos2(right.x - w * (n - k) as f32, right.y - h / 2.0), vec2(w, h)); - p.rect_filled(r, 0.0, Color32::WHITE); +/// Parent icon height. +const PARENT_THUMB_H: f32 = 24.0; + +/// Paint a parent's icon right-aligned at `right`: its rendered spread, or blank pages of `aspect` +/// for [None] (`parent` = `None`) and when the spread can't be rendered. +fn parent_icon(app: &mut DesignApp, ui: &egui::Ui, right: egui::Pos2, parent: Option<(usize, SpreadId)>, aspect: f32, t: &Tokens) { + let h = PARENT_THUMB_H; + let doc = app.session.active().map(|st| st.doc.clone()); + let spread = parent.and_then(|(i, _)| doc.as_ref()?.parents.get(i).cloned()); + // Page outlines in icon coordinates (left, width), from the spread's own pages. + let (pages, total): (Vec<(f32, f32)>, f32) = match &spread { + Some(sp) if sp.bounds().height() > 0.0 => { + let b = sp.bounds(); + let k = h as f64 / b.height(); + (sp.pages.iter().map(|p| (((p.x - b.x0) * k) as f32, (p.width * k) as f32)).collect(), (b.width() * k) as f32) + } + _ => { + let n = spread.as_ref().map_or(1, |sp| sp.pages.len().max(1)); + let w = h * aspect; + ((0..n).map(|k| (w * k as f32, w)).collect(), w * n as f32) + } + }; + let area = egui::Rect::from_min_size(egui::pos2(right.x - total, right.y - h / 2.0), vec2(total, h)); + let tex = parent.and_then(|(_, id)| thumb(app, ui.ctx(), ThumbOf::Parent(id), h)); + let p = ui.painter(); + match tex { + Some(tex) => { + p.image(tex.id(), area, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + p.rect_filled(area, 0.0, Color32::WHITE); + } + } + for (x, w) in pages { + let r = egui::Rect::from_min_size(egui::pos2(area.min.x + x, area.min.y), vec2(w, h)); p.rect_stroke(r, 0.0, Stroke::new(0.5, t.border), StrokeKind::Inside); } } @@ -80,26 +130,38 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let aspect = (doc.settings.page_width / doc.settings.page_height.max(1.0)) as f32; let mut drop: Option<(PageDrag, usize)> = None; ui.spacing_mut().item_spacing.y = 0.0; - // ---- Parents: [None] and each parent, 29 pt rows with a blank thumbnail at the right. - let mut rows: Vec<(String, Option, usize)> = vec![("[None]".into(), None, 1)]; - for p in &doc.parents { + // ---- Parents: [None] and each parent, 29 pt rows with the parent's thumbnail at the right. + // While parents are edited, the row of the parent the canvas shows is selected. + let cur_parent = if editing_parents { + let layout = designcraft_tools::CanvasLayout::new(&doc, true); + crate::canvas::current_slot(app, &layout).and_then(|i| layout.slots.get(i)).and_then(|s| match s.spread { + SpreadRef::Parent(i) => Some(i), + SpreadRef::Doc(_) => None, + }) + } else { + None + }; + let mut rows: Vec<(String, Option<(usize, SpreadId)>)> = vec![("[None]".into(), None)]; + for (i, p) in doc.parents.iter().enumerate() { let label = p.parent.as_ref().map(|i| i.label()).unwrap_or_default(); - rows.push((label, p.parent.as_ref().map(|i| i.prefix.clone()), p.pages.len())); + rows.push((label, Some((i, p.id)))); } - for (label, prefix, n) in rows { + for (label, parent) in rows { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); - let active = editing_parents && prefix.is_some(); + let active = parent.is_some_and(|(i, _)| cur_parent == Some(i)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, &label)); if active { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { ui.painter().rect_filled(r, 0.0, t.hover); } ui.painter().text(r.left_center() + vec2(3.0, 0.0), egui::Align2::LEFT_CENTER, &label, egui::FontId::proportional(11.5), t.text_strong); - blank_spread(ui.painter(), egui::pos2(r.max.x - 10.0, r.center().y), n, aspect, &t); - resp.dnd_set_drag_payload(PageDrag::Parent(prefix.clone())); - if resp.double_clicked() && prefix.is_some() { - let _ = app.run("layout.parents.edit", json!({"on": true})); - app.views.clear(); + parent_icon(app, ui, egui::pos2(r.max.x - 10.0, r.center().y), parent, aspect, &t); + resp.dnd_set_drag_payload(PageDrag::Parent(parent.map(|(_, id)| id))); + if resp.double_clicked() + && let Some((_, id)) = parent + { + let _ = app.run("layout.parents.edit", json!({"on": true, "parent": id.0})); } if resp.dragged() { drag_ghost(ui, &label, &t); @@ -132,7 +194,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let resp = ui.interact(pr, ui.id().with(("page", abs)), Sense::click_and_drag()); let selected = abs == cur && !editing_parents; let tint = if selected { SELECTED_TINT } else { Color32::WHITE }; - if let Some(tex) = thumb(app, ui.ctx(), abs, THUMB_H) { + if let Some(tex) = thumb(app, ui.ctx(), ThumbOf::Page(abs), THUMB_H) { ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint); } else { ui.painter().rect_filled(pr, 0.0, tint); @@ -167,7 +229,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if resp.clicked() || resp.double_clicked() { if editing_parents { let _ = app.run("layout.parents.edit", json!({"on": false})); - app.views.clear(); + // The canvas refits to the page's spread once it is centred. + if let Some(v) = app.view_mut() { + v.fitted = false; + } } crate::canvas::go_to_page(app, abs); } @@ -209,8 +274,8 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { Some((PageDrag::Page(from), to)) if from != to => { let _ = app.run("layout.pages.move", json!({"from": from, "to": to})); } - Some((PageDrag::Parent(prefix), to)) => { - let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": prefix})); + Some((PageDrag::Parent(parent), to)) => { + let _ = app.run("layout.pages.applyParent", json!({"pages": [to], "parent": parent.map(|id| id.0)})); } _ => {} } @@ -264,7 +329,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu for pp in &doc.parents { let l = pp.parent.as_ref().map(|i| i.label()).unwrap_or_default(); if ui.button(&l).clicked() { - let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": pp.parent.as_ref().map(|i| i.prefix.clone())})); + let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": pp.id.0})); ui.close(); } } @@ -280,3 +345,49 @@ fn drag_ghost(ui: &egui::Ui, label: &str, t: &Tokens) { painter.rect(r, 2.0, t.panel_darker, Stroke::new(1.0, t.field_border), StrokeKind::Inside); painter.galley(r.min + vec2(5.0, 3.0), g, t.text_strong); } + +#[cfg(test)] +mod tests { + use egui::{Event, Modifiers, PointerButton, vec2}; + use egui_kittest::kittest::{NodeT, Queryable}; + use serde_json::json; + + use crate::test_window; + + #[test] + fn double_clicking_a_parent_shows_that_parent() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for prefix in ["B", "C", "D"] { + app.run("layout.parents.new", json!({"prefix": prefix})).unwrap(); + } + app.ui.dock_tab = "pages".into(); + let mut h = test_window::open(app, vec2(1400.0, 900.0)); + let names = ["A-Parent", "B-Parent", "C-Parent", "D-Parent"]; + for name in names { + assert_ne!(h.get_by_label(name).accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{name} is not selected yet"); + } + // Both clicks in one frame: the harness's event queue spaces events too far apart for a + // double click. + let at = h.get_by_label("C-Parent").rect().center(); + h.input_mut().events.push(Event::PointerMoved(at)); + for pressed in [true, false, true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(4); + let app = &h.state().app; + assert!(app.session.active().unwrap().editing_parents); + let st = app.session.active().unwrap(); + let layout = designcraft_tools::CanvasLayout::new(&st.doc, true); + let slot = crate::canvas::current_slot(app, &layout).unwrap(); + assert_eq!(layout.slots[slot].spread, designcraft_doc::SpreadRef::Parent(2)); + for name in names { + assert_eq!(h.get_by_label(name).accesskit_node().toggled() == Some(egui::accesskit::Toggled::True), name == "C-Parent", "{name}"); + } + // Parent rows show a rendered thumbnail. + let id = st.doc.parents[2].id; + let rev = (st.uid, st.revision); + let thumbs = super::THUMBS.lock().unwrap(); + assert!(thumbs.as_ref().unwrap().keys().any(|k| (k.0, k.1) == rev && k.2 == super::ThumbOf::Parent(id))); + } +}