Pages panel: double-clicking a parent shows that parent; parents are identified by id

- layout.parents.edit takes `parent` (id, full name, or a prefix only one
  parent has) and asks the canvas to fit that parent's spread; turning
  editing on without one shows the first parent. The result names the
  parent shown.
- Commands resolve parents by id, then full name, then a unique prefix.
  Unknown or ambiguous names are errors, so a typo no longer clears a
  page's parent; null or "[None]" clears it.
- Pages panel: only the row of the parent the canvas shows is selected;
  rows, drag payloads and the Apply Parent menu carry the parent id.
  Parent rows show rendered thumbnails of the parent spread, cached by
  document revision like page thumbnails.
- Delete All Guides on a parent acts on the parent the canvas shows.

Fixes #624.
This commit is contained in:
aiongg
2026-10-12 19:49:59 +00:00
parent c3859c828b
commit 4e7b33607e
5 changed files with 312 additions and 61 deletions
+2 -1
View File
@@ -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.
+126 -18
View File
@@ -82,14 +82,14 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<usize> = p
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
.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<CommandSpec> {
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<CommandSpec> {
]
}
/// `"A"` → that parent's id; `null` → Some(None) (= [None]); missing → None (use default).
fn parent_ref(d: &designcraft_doc::Document, v: Option<&Value>) -> Option<Option<SpreadId>> {
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<Option<Option<SpreadId>>> {
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<SpreadId> {
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<u64> {
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<u64> =
["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);
}
}
+25 -1
View File
@@ -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<usize> {
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::<SpreadRef>(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) {
+8 -1
View File
@@ -979,7 +979,14 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
"app.deleteAllGuides" => {
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" => {
+151 -40
View File
@@ -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<Option<HashMap<ThumbKey, egui::TextureHandle>>> = Mutex::new(None);
fn thumb(app: &mut DesignApp, ctx: &egui::Context, abs: usize, h: f32) -> Option<egui::TextureHandle> {
/// 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<egui::TextureHandle> {
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<String>),
/// A parent (`None` = [None]).
Parent(Option<SpreadId>),
}
/// 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<String>, 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)));
}
}