From 4f45a0127a0a12f0c04699b3e8463d493ec5cd59 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Fri, 23 Oct 2026 20:58:19 +0900 Subject: [PATCH 1/4] #1620: Resize selected parent pages from page controls --- ROADMAP.md | 4 +- crates/doc/src/selection.rs | 5 +- crates/engine/src/cmd/layout.rs | 218 +++++++++----- crates/engine/src/cmd/liquid.rs | 4 +- crates/engine/src/cmd/parent_resize_tests.rs | 299 +++++++++++++++++++ crates/engine/src/tests.rs | 56 ++++ crates/tools/src/layout.rs | 46 ++- crates/tools/src/nav.rs | 83 +++-- crates/tools/src/tests.rs | 35 +++ crates/ui-egui/src/canvas.rs | 30 +- crates/ui-egui/src/chrome.rs | 122 +++++++- crates/ui-egui/src/control.rs | 21 +- crates/ui-egui/src/dialogs.rs | 30 ++ crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 48 ++- docs/roadmap.md | 4 +- 16 files changed, 855 insertions(+), 152 deletions(-) create mode 100644 crates/engine/src/cmd/parent_resize_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index d3ef17e47..349f68fe2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ **Stage: pre-alpha** · next: alpha, ~5–7 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%) - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (independent parent page resizing, #1620; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026 - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. - AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md. @@ -348,6 +348,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-23** · Parent page size edits target the selected parent page from Properties, the Page Tool Control panel and page handles (#1620), preserving independent document page sizes, Undo/Redo and the existing preview/Alt-drag behavior. No readiness percentage change. + - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 11c4e2ab1..a2dcf29da 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::edit::SpreadRef; -use crate::ids::{ItemId, StoryId}; +use crate::ids::{ItemId, SpreadId, StoryId}; use crate::table::CellRange; /// A selected ruler guide: index `index` in the guides of page `page` of spread `spread`. @@ -93,6 +93,9 @@ pub struct Selection { /// The page selected with the Page tool (absolute index). #[serde(default, skip_serializing_if = "Option::is_none")] pub page: Option, + /// Parent selected with the Page tool: stable spread id and page index within it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_page: Option<(SpreadId, usize)>, /// Selected ruler guides. Never together with items, text or cells: selecting one clears /// the other. #[serde(default, skip_serializing_if = "Vec::is_empty")] diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 047bd90ba..a53fed36b 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -622,6 +622,30 @@ pub fn specs() -> Vec { has_doc, page_size ), + cmd!( + "layout.parentPageSize", + "Parent Page Size", + [], + None, + "{parent: spread id, page: 0-based parent page index, width?, height?, anchor?: 0–8} — resize only this parent page, keeping document page sizes independent", + has_doc, + parent_page_size + ), + cmd!( + noundo "layout.selectParentPage", "Select Parent Page", [], None, + "{parent: spread id|null, page?: 0-based parent page index} — select a parent page with the Page tool; null clears the selection", + has_doc, |s, p| { + let target = match p.get("parent") { + Some(Value::Null) => None, + _ => Some(parent_page_target(&s.doc()?.doc, p, "layout.selectParentPage")?), + }; + let st = s.doc_mut()?; + st.selection = Default::default(); + st.selection.parent_page = target.map(|(si, pi)| (st.doc.parents[si].id, pi)); + st.bump_revision(); + ok() + } + ), cmd!( "layout.pageOptions", "Page Options", @@ -656,6 +680,7 @@ pub fn specs() -> Vec { st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; + st.selection.parent_page = None; st.bump_revision(); Ok(json!({"page": page.map(|p| p + 1)})) } @@ -1021,6 +1046,43 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { }) } +fn parent_page_target(d: &designcraft_doc::Document, p: &Value, cmd: &str) -> Result<(usize, usize)> { + let parent = p.get("parent").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad(cmd, "give a parent spread id"))?; + let si = d.parent_index(parent).ok_or_else(|| bad(cmd, "no such parent"))?; + let pi = p + .get("page") + .and_then(Value::as_u64) + .and_then(|n| usize::try_from(n).ok()) + .filter(|i| *i < d.parents[si].pages.len()) + .ok_or_else(|| bad(cmd, "no such parent page"))?; + Ok((si, pi)) +} + +fn parent_page_size(s: &mut Session, p: &Value) -> Result { + const ID: &str = "layout.parentPageSize"; + let (si, pi) = parent_page_target(&s.doc()?.doc, p, ID)?; + let size = |key| { + p.get(key) + .map(|v| { + v.as_f64().filter(|v| v.is_finite() && *v > 0.0 && *v <= 15552.0).ok_or_else(|| bad(ID, "page size out of range (0 < size ≤ 216 in)")) + }) + .transpose() + }; + let (w, h) = (size("width")?, size("height")?); + if w.is_none() && h.is_none() { + return Err(bad(ID, "give width or height")); + } + let anchor = match p.get("anchor") { + None => 0, + Some(v) => v.as_u64().filter(|n| *n <= 8).ok_or_else(|| bad(ID, "anchor: 0–8"))? as usize, + }; + s.edit(|d, _| { + resize_spread_page(d, designcraft_doc::SpreadRef::Parent(si), pi, w, h, anchor); + d.sync_parent_margins(); + Ok(json!({"parent": d.parents[si].id.0, "page": pi})) + }) +} + fn page_options(s: &mut crate::Session, p: &Value) -> Result { let pages: Vec = p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); @@ -1050,84 +1112,86 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w /// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: /// the page grows away from it, so objects keep their place relative to it. pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { + for page in pages { + let Some((si, pi)) = page.checked_sub(1).and_then(|i| d.page_loc(i)) else { continue }; + resize_spread_page(d, designcraft_doc::SpreadRef::Doc(si), pi, w.map(|v| v.max(1.0)), h.map(|v| v.max(1.0)), anchor); + } +} + +fn resize_spread_page(d: &mut designcraft_doc::Document, sr: designcraft_doc::SpreadRef, pi: usize, w: Option, h: Option, anchor: usize) { let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); - { - for page in pages { - let Some((si, pi)) = d.page_loc(page - 1) else { continue }; - let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); - let old: Vec = sp.pages.iter().map(|x| x.x).collect(); - let old_bounds = sp.pages[pi].bounds(); - // The page's own objects (outer pages take the pasteboard beside them). - let last = sp.pages.len() - 1; - let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); - let own: Vec = sp - .items - .iter() - .enumerate() - .filter(|(_, it)| { - let c = it.bounds().center().x; - c >= lo && c < hi - }) - .map(|(i, _)| i) - .collect(); - let pg = &mut sp.pages[pi]; - let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); - let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); - if let Some(w) = w { - set_page_width(pg, w.max(1.0)); - } - if let Some(h) = h { - pg.height = h.max(1.0); - } - let left = pg.side == designcraft_doc::PageSide::Left; - sp.relayout(); - // Keep the spine: shift the whole spread back when a left page grows. - if left && dw != 0.0 { - for q in &mut sp.pages { - q.x -= dw; - } - } - let new: Vec = sp.pages.iter().map(|x| x.x).collect(); - // Objects follow their page. - let shifts: Vec<(f64, f64, f64)> = - sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); - for it in &mut sp.items { - let cx = it.bounds().center().x; - if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) - && *dx != 0.0 - { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; - } - } - // Page guides move with their page. - for (k, q) in sp.pages.iter_mut().enumerate() { - let dx = new[k] - old[k]; - if dx != 0.0 { - for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { - g.position += dx; - } - } - } - // Growing away from the anchor: the page's objects and guides keep their place - // relative to it. - let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); - if shift != designcraft_geom::Vec2::ZERO { - for i in &own { - if let Some(it) = sp.items.get_mut(*i) { - let it = std::sync::Arc::make_mut(it); - it.xf = designcraft_geom::Affine::translate(shift) * it.xf; - } - } - for g in &mut sp.pages[pi].guides { - g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; - } - } - // Liquid rules: objects were carried along with the page's left edge (and the anchor). - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; - super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); + let Some(sp) = d.spread_mut(sr) else { return }; + let origin = sp.bounds().x0; + let old: Vec = sp.pages.iter().map(|x| x.x).collect(); + let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return }; + let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == rightmost { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); + let pg = &mut sp.pages[pi]; + let dw = w.map_or(0.0, |w| w - pg.width); + let dh = h.map_or(0.0, |h| h - pg.height); + if let Some(w) = w { + set_page_width(pg, w); + } + if let Some(h) = h { + pg.height = h; + } + let left = pg.side == designcraft_doc::PageSide::Left; + sp.relayout(); + // Relayout resets the origin; retain prior edits and grow left pages away from the spine. + let offset = origin - if left { dw } else { 0.0 }; + for q in &mut sp.pages { + q.x += offset; + } + let new: Vec = sp.pages.iter().map(|x| x.x).collect(); + // Objects follow their page. + let shifts: Vec<(f64, f64, f64)> = + sp.pages.iter().enumerate().map(|(k, q)| (old[k], old[k] + q.width - if k == pi { dw } else { 0.0 }, new[k] - old[k])).collect(); + for it in &mut sp.items { + let cx = it.bounds().center().x; + if let Some((_, _, dx)) = shifts.iter().find(|(a, b, _)| cx >= *a && cx < *b) + && *dx != 0.0 + { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate((*dx, 0.0)) * it.xf; } } + // Page guides move with their page. + for (k, q) in sp.pages.iter_mut().enumerate() { + let dx = new[k] - old[k]; + if dx != 0.0 { + for g in q.guides.iter_mut().filter(|g| g.orientation == designcraft_doc::Orientation::Vertical) { + g.position += dx; + } + } + } + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; + super::liquid::apply(d, sr, pi, moved); } /// File › Document Setup values. @@ -2497,3 +2561,7 @@ mod parent_base_tests { #[cfg(test)] #[path = "parent_delete_tests.rs"] mod parent_delete_tests; + +#[cfg(test)] +#[path = "parent_resize_tests.rs"] +mod parent_resize_tests; diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 8c8d4be23..5f09a0c89 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -70,8 +70,8 @@ pub(crate) fn apply(d: &mut Document, sr: SpreadRef, pi: usize, old: Rect) { let liquid_guides = |o: Orientation| pg.guides.iter().filter(|g| g.liquid && g.orientation == o).map(|g| g.position).collect::>(); let (vg, hg) = (liquid_guides(Orientation::Vertical), liquid_guides(Orientation::Horizontal)); // The page's objects: centres within its old horizontal extent (outer pages take the pasteboard). - let last = sp.pages.len() - 1; - let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old.x0 }, if pi == last { f64::INFINITY } else { old.x1 }); + let Some((leftmost, rightmost)) = sp.outer_page_indices() else { return }; + let (lo, hi) = (if pi == leftmost { f64::NEG_INFINITY } else { old.x0 }, if pi == rightmost { f64::INFINITY } else { old.x1 }); let strokes = false; let Some(sp) = d.spread_mut(sr) else { return }; for it in &mut sp.items { diff --git a/crates/engine/src/cmd/parent_resize_tests.rs b/crates/engine/src/cmd/parent_resize_tests.rs new file mode 100644 index 000000000..55ba22976 --- /dev/null +++ b/crates/engine/src/cmd/parent_resize_tests.rs @@ -0,0 +1,299 @@ +use std::sync::Arc; + +use designcraft_doc::{Columns, LiquidRule, PageSide, SpreadId}; +use designcraft_geom::Rect; +use serde_json::json; + +use crate::Session; + +fn parent_id(s: &Session, index: usize) -> u64 { + s.doc().unwrap().doc.parents[index].id.0 +} + +fn parent_page(s: &Session, index: usize, page: usize) -> designcraft_doc::Page { + s.doc().unwrap().doc.parents[index].pages[page].clone() +} + +fn parent_frame(s: &mut Session, x: f64) -> u64 { + s.execute( + "frame.create", + &json!({ + "spread": {"kind": "parent", "index": 0}, + "rect": [x, 80.0, x + 100.0, 180.0] + }), + ) + .unwrap()["id"] + .as_u64() + .unwrap() +} + +fn bounds(s: &Session, id: u64) -> Rect { + s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds() +} + +fn guide_positions(s: &Session, page: usize) -> Vec { + s.doc().unwrap().doc.parents[0].pages[page].guides.iter().map(|g| g.position).collect() +} + +fn prepare_parent_pages(s: &mut Session, binding: &str, rule: LiquidRule) -> (u64, u64, u64, usize, usize) { + s.execute("file.new", &json!({"pages": 2, "facingPages": true, "width": 400.0, "height": 400.0})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding": binding})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.right_to_left_binding, binding == "rightToLeft"); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].side, if binding == "rightToLeft" { PageSide::Right } else { PageSide::Left }); + let target = s.doc().unwrap().doc.parents[0].pages.iter().position(|p| p.side == PageSide::Right).unwrap(); + let other = 1 - target; + let target_page = parent_page(s, 0, target); + let other_page = parent_page(s, 0, other); + let target_item = parent_frame(s, target_page.x + 80.0); + let other_item = parent_frame(s, other_page.x + 80.0); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "vertical", "position": target_page.x + 120.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": target, "orientation": "horizontal", "position": 120.0, "at": target_page.x + 10.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "vertical", "position": other_page.x + 120.0}), + ) + .unwrap(); + s.execute( + "guide.add", + &json!({"spread": {"kind": "parent", "index": 0}, "page": other, "orientation": "horizontal", "position": 120.0, "at": other_page.x + 10.0}), + ) + .unwrap(); + s.edit(|d, _| { + let sp = Arc::make_mut(&mut d.parents[0]); + let positions = |page: &designcraft_doc::Page| { + let w = page.margin_rect().width(); + vec![0.0, 100.0, 120.0, w] + }; + for page in &mut sp.pages { + page.columns = Columns { count: 2, gutter: 20.0, positions: Some(positions(page)) }; + } + sp.pages[target].liquid = rule; + Ok(()) + }) + .unwrap(); + (parent_id(s, 0), target_item, other_item, target, other) +} + +#[test] +fn parent_resize_changes_only_the_target_page_in_ltr_and_rtl() { + for binding in ["leftToRight", "rightToLeft"] { + for rule in [ + LiquidRule::Off, + LiquidRule::Scale, + LiquidRule::ReCenter, + LiquidRule::ObjectBased, + LiquidRule::GuideBased, + LiquidRule::ControlledByParent, + ] { + let mut s = Session::new(); + let (parent, target_item, other_item, target, other) = prepare_parent_pages(&mut s, binding, rule); + let before_target = parent_page(&s, 0, target); + let before_other = parent_page(&s, 0, other); + let before_target_item = bounds(&s, target_item); + let before_other_item = bounds(&s, other_item); + let before_target_guides = guide_positions(&s, target); + let before_other_guides = guide_positions(&s, other); + let before_target_columns = before_target.columns.positions.clone(); + let before_other_columns = before_other.columns.positions.clone(); + + s.execute("layout.selectParentPage", &json!({"parent": parent, "page": target})).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), target))); + assert!(s.doc().unwrap().selection.items.is_empty()); + s.execute("layout.parentPageSize", &json!({"parent": parent, "page": target, "width": 500.0, "height": 500.0, "anchor": 8})).unwrap(); + + let after_target = parent_page(&s, 0, target); + let after_other = parent_page(&s, 0, other); + assert_eq!((after_target.width, after_target.height), (500.0, 500.0), "{binding} {rule:?}"); + assert_eq!((after_other.width, after_other.height), (before_other.width, before_other.height), "{binding} {rule:?}"); + assert_ne!(bounds(&s, target_item), before_target_item, "target object did not follow the resize: {binding} {rule:?}"); + assert_eq!(bounds(&s, other_item), before_other_item, "other page object moved: {binding} {rule:?}"); + assert_ne!(guide_positions(&s, target), before_target_guides, "target guides did not follow the resize: {binding} {rule:?}"); + assert_eq!(guide_positions(&s, other), before_other_guides, "other page guides moved: {binding} {rule:?}"); + assert_ne!(after_target.columns.positions, before_target_columns, "target columns did not scale: {binding} {rule:?}"); + assert_eq!(after_other.columns.positions, before_other_columns, "other page columns changed: {binding} {rule:?}"); + assert_eq!(s.doc().unwrap().doc.settings.page_width, 400.0); + assert_eq!(s.doc().unwrap().doc.settings.page_height, 400.0); + s.doc().unwrap().doc.check().unwrap(); + } + } +} + +#[test] +fn parent_resize_is_independent_of_applied_and_based_on_parents_and_undoable() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap(); + let a = parent_id(&s, 0); + let b = s.execute("layout.parents.new", &json!({"prefix": "B", "name": "Child"})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.parents.options", &json!({"parent": b, "basedOn": a})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": b})).unwrap(); + let before = s.doc().unwrap().doc.as_ref().clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + + s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "anchor": 0})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.parents[0].pages[0].width, 0.5); + assert_eq!(d.parents[1].pages[0].width, before.parents[1].pages[0].width); + assert_eq!(d.spreads[0].pages[0].width, before.spreads[0].pages[0].width); + assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(SpreadId(a))); + assert_eq!(d.settings.page_width, before.settings.page_width); + d.check().unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, before.parents[0].pages[0].width); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].pages[0].width, 0.5); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn parent_resize_survives_native_and_idml_round_trips() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false, "width": 400.0, "height": 400.0})).unwrap(); + let a = parent_id(&s, 0); + s.execute("layout.parentPageSize", &json!({"parent": a, "page": 0, "width": 0.5, "height": 0.75})).unwrap(); + let d = s.doc().unwrap().doc.as_ref().clone(); + d.check().unwrap(); + + let native = crate::cmd::from_bytes(&crate::cmd::to_bytes(&d).unwrap()).unwrap(); + assert_eq!((native.parents[0].pages[0].width, native.parents[0].pages[0].height), (0.5, 0.75)); + native.check().unwrap(); + + let idml = designcraft_idml::export_idml(&d); + let reopened = designcraft_idml::import_idml(&idml).unwrap(); + assert_eq!((reopened.parents[0].pages[0].width, reopened.parents[0].pages[0].height), (0.5, 0.75)); + reopened.check().unwrap(); +} + +#[test] +fn parent_resize_rejects_invalid_inputs_atomically() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = parent_id(&s, 0); + let before = s.doc().unwrap().doc.as_ref().clone(); + let undo_before = s.doc().unwrap().history.undo.len(); + let invalid = [ + json!({"parent": parent, "page": 0, "width": null}), + json!({"parent": parent, "page": 0, "width": "400"}), + json!({"parent": parent, "page": 0, "width": "NaN"}), + json!({"parent": parent, "page": 0, "width": "Infinity"}), + json!({"parent": parent, "page": 0, "width": 0}), + json!({"parent": parent, "page": 0, "width": -1}), + json!({"parent": parent, "page": 0, "width": 15552.1}), + json!({"parent": parent, "page": 0, "width": 400, "anchor": 9}), + json!({"parent": parent, "page": 1, "width": 400}), + json!({"parent": 999_999, "page": 0, "width": 400}), + ]; + for params in invalid { + assert!(s.execute("layout.parentPageSize", ¶ms).is_err(), "accepted {params}"); + assert_eq!(s.doc().unwrap().doc.as_ref(), &before, "mutated after {params}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before, "added undo after {params}"); + } + assert!(s.execute("layout.parentPageSize", &json!({"parent": parent, "page": 0})).is_err()); + assert_eq!(s.doc().unwrap().doc.as_ref(), &before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + s.doc().unwrap().doc.check().unwrap(); +} + +#[test] +fn parent_and_document_page_selection_validate_and_clear_each_other() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + let parent = parent_id(&s, 0); + let item = s.execute("frame.create", &json!({"rect": [40, 40, 100, 100]})).unwrap()["id"].as_u64().unwrap(); + assert!(!s.doc().unwrap().selection.items.is_empty()); + s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0))); + assert!(s.doc().unwrap().selection.items.is_empty()); + assert!(s.execute("layout.selectParentPage", &json!({"parent": parent, "page": 1})).is_err()); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((SpreadId(parent), 0))); + s.execute("layout.selectPage", &json!({"page": 1, "ids": [item]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, Some(0)); + assert_eq!(s.doc().unwrap().selection.parent_page, None); + s.execute("layout.selectParentPage", &json!({"parent": null})).unwrap(); + assert_eq!(s.doc().unwrap().selection, Default::default()); +} + +#[test] +fn parent_resize_repeated_edits_keep_the_spine_and_other_page_objects() { + for binding in ["leftToRight", "rightToLeft"] { + for pi in 0..2 { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap(); + let id = parent_id(&s, 0); + let before = s.doc().unwrap().doc.clone(); + let spine = before.parents[0].spine_x(); + let other = 1 - pi; + let ox = before.parents[0].pages[other].x + 80.0; + let object = parent_frame(&mut s, ox); + let old_object = bounds(&s, object); + for params in + [json!({"parent":id,"page":pi,"width":500}), json!({"parent":id,"page":pi,"height":700}), json!({"parent":id,"page":pi,"width":550})] + { + s.execute("layout.parentPageSize", ¶ms).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[0].spine_x(), spine, "spine moved: {binding}, page {pi}, {params}"); + assert_eq!(bounds(&s, object), old_object, "other-page object moved: {binding}, page {pi}"); + } + } + } +} + +#[test] +fn parent_resize_keeps_margin_and_column_inheritance_without_resizing_applied_pages() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3})).unwrap(); + let id = parent_id(&s, 0); + s.edit(|d, _| { + let pg = &mut Arc::make_mut(&mut d.parents[0]).pages[1]; + pg.columns = Columns { count: 2, gutter: 20.0, positions: Some(vec![0.0, 100.0, 120.0, 328.0]) }; + d.sync_parent_margins(); + Arc::make_mut(&mut d.spreads[1]).pages[1].local_margins = true; + Ok(()) + }) + .unwrap(); + let before = s.doc().unwrap().doc.clone(); + s.execute("layout.parentPageSize", &json!({"parent":id,"page":1,"width":500})).unwrap(); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page(0).unwrap().columns, d.parents[0].pages[1].columns); + assert_eq!(d.page(0).unwrap().margins, d.parents[0].pages[1].margins); + assert_eq!((d.page(0).unwrap().width, d.page(0).unwrap().height), (400.0, 600.0)); + assert_eq!(d.page(2).unwrap(), before.page(2).unwrap()); + d.check().unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":null})).unwrap(); + s.execute("layout.pages.applyParent", &json!({"pages":[0],"parent":id})).unwrap(); + assert_eq!((s.doc().unwrap().doc.page(0).unwrap().width, s.doc().unwrap().doc.page(0).unwrap().height), (400.0, 600.0)); +} + +#[test] +fn parent_resize_facing_sizes_survive_idml_in_both_bindings() { + for binding in ["leftToRight", "rightToLeft"] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + s.execute("layout.documentSetup", &json!({"binding":binding})).unwrap(); + let id = parent_id(&s, 0); + for (pi, w, h) in [(0, 500, 700), (1, 300, 550)] { + s.execute("layout.parentPageSize", &json!({"parent":id,"page":pi,"width":w,"height":h})).unwrap(); + } + let d = &s.doc().unwrap().doc; + let reopened = designcraft_idml::import_idml(&designcraft_idml::export_idml(d)).unwrap(); + assert_eq!(reopened.settings.right_to_left_binding, d.settings.right_to_left_binding); + for page in &d.parents[0].pages { + let restored = reopened.parents[0].pages.iter().find(|p| p.side == page.side).unwrap(); + assert_eq!((restored.width, restored.height), (page.width, page.height), "{binding}, {:?}", page.side); + } + for abs in 0..d.page_count() { + let (old, new) = (d.page(abs).unwrap(), reopened.page(abs).unwrap()); + assert_eq!((new.width, new.height), (old.width, old.height)); + } + reopened.check().unwrap(); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 42f57b3a1..3e1773853 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1651,3 +1651,59 @@ fn a_line_fits_when_its_baseline_does() { assert!(!overset(&mut s, 78.5), "the line is set; its descenders hang below the frame"); assert!(overset(&mut s, 50.0), "a baseline below the frame still oversets the line"); } + +#[test] +fn parent_resize_tool_previews_cancels_commits_and_undoes_on_the_selected_spread() { + use designcraft_tools::{Mods, Overlay, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + s.execute("layout.parents.new", &json!({"prefix":"B"})).unwrap(); + s.execute("layout.parents.edit", &json!({"on":true})).unwrap(); + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let before = s.doc().unwrap().doc.clone(); + for si in 0..2 { + for pi in 0..2 { + let sr = designcraft_doc::SpreadRef::Parent(si); + let rect = |s: &Session| s.layout().spread_page_rect(&s.doc().unwrap().doc, sr, pi).unwrap(); + let c = rect(&s).center(); + s.pointer(&PointerEvent::new(PointerKind::Down, c.x, c.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, c.x, c.y), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.parent_page, Some((before.parents[si].id, pi))); + assert_eq!(s.doc().unwrap().selection.page, None); + assert!(s.overlays(v).iter().any(|o| matches!(o,Overlay::PageHandles(r) if *r==rect(&s)))); + for alt in [false, true] { + let original = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let r = rect(&s); + let p = designcraft_geom::Point::new(r.x1, r.center().y); + let ev = |kind, x| PointerEvent::new(kind, x, p.y).with_mods(Mods { alt, ..Default::default() }); + s.pointer(&ev(PointerKind::Down, p.x), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, p.x + 80.0), v).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + assert_eq!(s.doc().unwrap().doc.spreads, before.spreads); + assert_eq!(s.doc().unwrap().doc.parents[1 - si], before.parents[1 - si]); + s.pointer(&ev(PointerKind::Up, p.x + 80.0), v).unwrap(); + if alt { + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents, original.parents); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.parents[si].pages[pi].width, 480.0); + s.execute("edit.undo", &json!({})).unwrap(); + } else { + assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &original)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + } + } + let c = rect(&s).center(); + s.pointer(&PointerEvent::new(PointerKind::DoubleClick, c.x, c.y), v).unwrap(); + let req = std::mem::take(&mut s.ui_requests); + assert!( + matches!(req.last(),Some(crate::UiRequest::Dialog{id,params}) if id=="cmd:layout.parentPageSize" && params["parent"]==before.parents[si].id.0 && params["page"]==pi) + ); + s.doc().unwrap().doc.check().unwrap(); + } + } +} diff --git a/crates/tools/src/layout.rs b/crates/tools/src/layout.rs index 9f000fcd7..d5ea24b85 100644 --- a/crates/tools/src/layout.rs +++ b/crates/tools/src/layout.rs @@ -1,9 +1,10 @@ //! Where spreads sit on the canvas (the pasteboard): stacked vertically, spines aligned at x = 0, //! parents shown on their own canvas when a parent spread is being edited. -use designcraft_doc::{Document, SpreadRef}; +use designcraft_doc::{Document, Selection, SpreadRef}; use designcraft_geom::{Affine, Point, Rect, Vec2}; use serde::Serialize; +use serde_json::{Value, json}; #[derive(Clone, Copy, Debug, PartialEq, Serialize)] pub struct SpreadSlot { @@ -100,6 +101,31 @@ impl CanvasLayout { (m * Point::new(d.x, d.y)) - (m * Point::ORIGIN) } + /// The Page tool selection on the canvas currently shown. + pub fn selected_page(&self, doc: &Document, selection: &Selection) -> Option<(SpreadRef, usize)> { + let (sr, pi) = if self.slots.first().is_some_and(|s| matches!(s.spread, SpreadRef::Parent(_))) { + let (id, pi) = selection.parent_page?; + (SpreadRef::Parent(doc.parent_index(id)?), pi) + } else { + let (si, pi) = doc.page_loc(selection.page?)?; + (SpreadRef::Doc(si), pi) + }; + self.slot(sr)?; + doc.spread(sr)?.pages.get(pi)?; + Some((sr, pi)) + } + + /// A page on the visible canvas, including parent pages. + pub fn page_at(&self, doc: &Document, p: Point) -> Option<(SpreadRef, usize)> { + self.slots + .iter() + .find_map(|slot| doc.spread(slot.spread)?.pages.iter().position(|pg| pg.bounds().contains(slot.to_spread(p))).map(|pi| (slot.spread, pi))) + } + + pub fn spread_page_rect(&self, doc: &Document, sr: SpreadRef, pi: usize) -> Option { + Some(self.slot(sr)?.xf.transform_rect_bbox(doc.spread(sr)?.pages.get(pi)?.bounds())) + } + /// Canvas rect of a document page. pub fn page_rect(&self, doc: &Document, abs: usize) -> Option { let (si, pi) = doc.page_loc(abs)?; @@ -107,6 +133,24 @@ impl CanvasLayout { } } +/// The same resize contract for panels, handles and the exact-size dialog. +pub fn page_size_command(doc: &Document, sr: SpreadRef, page: usize, mut params: Value) -> Option<(&'static str, Value)> { + let sp = doc.spread(sr)?; + sp.pages.get(page)?; + let command = match sr { + SpreadRef::Doc(si) => { + params["pages"] = json!([doc.first_page_of_spread(si) + page + 1]); + "layout.pageSize" + } + SpreadRef::Parent(_) => { + params["parent"] = json!(sp.id.0); + params["page"] = json!(page); + "layout.parentPageSize" + } + }; + Some((command, params)) +} + fn dist_y(r: Rect, y: f64) -> f64 { if y < r.y0 - SPREAD_GAP / 2.0 { r.y0 - y diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index f77c5d0bb..bd6c6b2f4 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -249,7 +249,9 @@ fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(Liqui /// A page handle being dragged. #[derive(Clone, Copy, Debug)] struct PageDrag { - /// Absolute page index. + spread: designcraft_doc::SpreadRef, + parent: Option, + /// Page index within the spread. page: usize, /// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none). handle: usize, @@ -297,6 +299,9 @@ impl PageTool { fn liquid_target( cx: &ToolContext, ) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> { + if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) { + return None; + } let page = cx.selection.page?; let [id] = cx.selection.items.as_slice() else { return None }; let (si, _) = cx.doc.page_loc(page)?; @@ -316,17 +321,16 @@ impl PageTool { } /// The selected page's handle under `p`. - fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { - let page = cx.selection.page?; - let r = cx.layout.page_rect(cx.doc, page)?; - page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i)) + fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(designcraft_doc::SpreadRef, usize, usize)> { + let (sr, page) = cx.layout.selected_page(cx.doc, cx.selection)?; + let r = cx.layout.spread_page_rect(cx.doc, sr, page)?; + page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (sr, page, i)) } /// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in /// proportion). fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) { - let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0); - let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start); + let v = cx.layout.delta_to_spread(d.spread, p - d.start); let (col, row) = (d.handle % 3, d.handle / 3); let dx = match col { 0 => -v.x, @@ -361,10 +365,21 @@ impl Tool for PageTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { - if let Some((page, handle)) = self.handle_at(cx, ev.pos) { - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; - self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); + if let Some((spread, page, handle)) = self.handle_at(cx, ev.pos) { + let Some(pg) = cx.doc.spread(spread).and_then(|sp| sp.pages.get(page)) else { return vec![] }; + self.drag = Some(PageDrag { + spread, + parent: match spread { + designcraft_doc::SpreadRef::Parent(si) => cx.doc.parents.get(si).map(|sp| sp.id), + _ => None, + }, + page, + handle, + start: ev.pos, + cur: ev.pos, + size: (pg.width, pg.height), + alt: ev.mods.alt, + }); return vec![Action::Begin("Resize Page".into())]; } // An object-based page's object: a click on a pin or lock toggles it. @@ -373,7 +388,18 @@ impl Tool for PageTool { } // A click selects the page under the pointer (none on the pasteboard), and on an // object-based page the object clicked too. - let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); + let target = cx.layout.page_at(cx.doc, ev.pos); + if let Some((designcraft_doc::SpreadRef::Parent(si), page)) = target { + let Some(sp) = cx.doc.parents.get(si) else { return vec![] }; + return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": sp.id.0, "page": page}))]; + } + if cx.layout.slots.first().is_some_and(|s| matches!(s.spread, designcraft_doc::SpreadRef::Parent(_))) { + return vec![Action::Exec("layout.selectParentPage".into(), json!({"parent": null}))]; + } + let page = target.and_then(|(sr, pi)| match sr { + designcraft_doc::SpreadRef::Doc(si) => Some(cx.doc.first_page_of_spread(si) + pi), + _ => None, + }); let object = page .filter(|p| object_based(cx.doc, *p)) .and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p))); @@ -387,9 +413,21 @@ impl Tool for PageTool { let Some(d) = self.drag.as_mut() else { return vec![] }; d.cur = ev.pos; d.alt |= ev.mods.alt; - let d = *d; + let mut d = *d; + if let Some(id) = d.parent { + let Some(si) = cx.doc.parent_index(id) else { + self.drag = None; + return vec![Action::Cancel]; + }; + d.spread = designcraft_doc::SpreadRef::Parent(si); + } let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift); - vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))] + let Some((cmd, params)) = + crate::layout::page_size_command(cx.doc, d.spread, d.page, json!({"width": w, "height": h, "anchor": 8 - d.handle})) + else { + return vec![]; + }; + vec![Action::Preview(cmd.into(), params)] } PointerKind::Up => match self.drag.take() { // Alt-drag resizes; a plain drag previews the liquid rule and springs back. @@ -399,12 +437,12 @@ impl Tool for PageTool { }, PointerKind::DoubleClick => { // Exact sizes: the Page Size dialog for the page. - let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else { + let Some((sr, pi)) = cx.layout.page_at(cx.doc, ev.pos) else { return vec![] }; + let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] }; + let Some((cmd, params)) = crate::layout::page_size_command(cx.doc, sr, pi, json!({"width": pg.width, "height": pg.height})) else { return vec![]; }; - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; - vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] + vec![Action::Dialog(format!("cmd:{cmd}"), params)] } PointerKind::Move => { self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos)); @@ -413,10 +451,9 @@ impl Tool for PageTool { } } fn overlays(&self, cx: &ToolContext) -> Vec { - let Some(page) = cx.selection.page else { return vec![] }; - let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] }; - let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; - let pg = &cx.doc.spreads[si].pages[pi]; + let Some((sr, pi)) = cx.layout.selected_page(cx.doc, cx.selection) else { return vec![] }; + let Some(r) = cx.layout.spread_page_rect(cx.doc, sr, pi) else { return vec![] }; + let Some(pg) = cx.doc.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return vec![] }; let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; if let Some((_, l, object, page)) = Self::liquid_target(cx) { v.push(Overlay::LiquidPins { @@ -445,7 +482,7 @@ impl Tool for PageTool { if self.drag.is_none() && Self::control_at(cx, p).is_some() { return Cursor::Hand; } - let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); + let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, _, h)| h)); match handle { Some(1 | 7) => Cursor::ResizeV, Some(3 | 5) => Cursor::ResizeH, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index ee3b362f1..555ea9e1e 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -1936,3 +1936,38 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() { assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group"); assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed); } + +#[test] +fn parent_resize_page_tool_targets_the_visible_parent() { + let d = Document::new(&NewDocument { width: 400.0, height: 600.0, pages: 3, facing_pages: true, ..Default::default() }); + let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, true)); + let cx = ctx(&d, &s, &c, &l); + let p = l.to_canvas(SpreadRef::Parent(0), d.parents[0].pages[0].bounds().center()); + let mut t = create("page"); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, p.x, p.y)); + assert_eq!( + a, + vec![Action::Dialog( + "cmd:layout.parentPageSize".into(), + serde_json::json!({"parent": d.parents[0].id.0, "page": 0, "width": 400.0, "height": 600.0}) + )], + "double click must target the visible parent instead of document page zero" + ); +} + +#[test] +fn parent_resize_selection_tracks_ids_and_never_crosses_canvas_modes() { + let mut d = Document::new(&NewDocument::default()); + let second = d.add_parent("B", "Parent", 1, 12.0, designcraft_doc::Margins::uniform(36.0)); + let selection = Selection { page: Some(0), parent_page: Some((second, 1)), ..Default::default() }; + let l = CanvasLayout::new(&d, true); + assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(1), 1))); + d.parents.swap(0, 1); + let l = CanvasLayout::new(&d, true); + assert_eq!(l.selected_page(&d, &selection), Some((SpreadRef::Parent(0), 1))); + assert_eq!(CanvasLayout::new(&d, false).selected_page(&d, &selection), Some((SpreadRef::Doc(0), 0))); + d.parents.remove(0); + assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None); + let selection = Selection { page: Some(0), ..Default::default() }; + assert_eq!(CanvasLayout::new(&d, true).selected_page(&d, &selection), None); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 318f82388..496d17bdd 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -198,7 +198,7 @@ pub fn current_page(app: &DesignApp) -> Option { let layout = CanvasLayout::new(&st.doc, st.editing_parents); let i = current_slot(app, &layout)?; let slot = layout.slots.get(i)?; - let SpreadRef::Doc(si) = slot.spread else { return Some(0) }; + let SpreadRef::Doc(si) = slot.spread else { return None }; let v = app.view()?; let r = app.canvas_rect?; let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y)); @@ -206,6 +206,16 @@ pub fn current_page(app: &DesignApp) -> Option { Some(st.doc.first_page_of_spread(si) + pi) } +/// The Page tool selection, or the page in view, without crossing canvas modes. +pub fn page_target(app: &DesignApp) -> Option<(SpreadRef, usize)> { + let st = app.session.active()?; + let selected = CanvasLayout::new(&st.doc, st.editing_parents).selected_page(&st.doc, &st.selection); + if st.editing_parents && st.selection.parent_page.is_some() { + return selected; + } + selected.or_else(|| current_spread_page(app)) +} + /// The spread (document or parent) under the view centre and the page index within it: where /// commands that act "on the page I am working on" should go. pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> { @@ -220,22 +230,10 @@ pub fn current_spread_page(app: &DesignApp) -> Option<(SpreadRef, usize)> { Some((slot.spread, pg)) } -/// While parent spreads are being edited: (parent spread index, page index within it) under the -/// view centre. `None` when document pages are shown. +/// The selected parent page, or the parent page under the view centre. pub fn current_parent_page(app: &DesignApp) -> Option<(usize, usize)> { - let st = app.session.active()?; - if !st.editing_parents { - return None; - } - let layout = CanvasLayout::new(&st.doc, true); - let i = current_slot(app, &layout)?; - let slot = layout.slots.get(i)?; - let SpreadRef::Parent(pi) = slot.spread else { return None }; - let v = app.view()?; - let r = app.canvas_rect?; - let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y)); - let qi = st.doc.parents.get(pi)?.page_at_x(c.x).unwrap_or(0); - Some((pi, qi)) + let (SpreadRef::Parent(si), pi) = page_target(app)? else { return None }; + Some((si, pi)) } /// Scroll so absolute page `abs` is centred (keeps zoom). diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e853e6793..5235180e0 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -484,12 +484,20 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = app.ui.language.clone(); let Some(st) = app.session.active() else { return }; let units = st.doc.settings.horizontal_units; - let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return }; + let Some((spread, page)) = crate::canvas::page_target(app) else { return }; let Some(st) = app.session.active() else { return }; let d = st.doc.clone(); - let Some((si, pi)) = d.page_loc(page) else { return }; - let pg = d.spreads[si].pages[pi].clone(); - let n = page + 1; + let Some(sp) = d.spread(spread) else { return }; + let Some(pg) = sp.pages.get(page).cloned() else { return }; + let n = match spread { + designcraft_doc::SpreadRef::Doc(si) => Some(d.first_page_of_spread(si) + page + 1), + _ => None, + }; + let resize = |app: &mut DesignApp, params| { + if let Some((cmd, params)) = designcraft_tools::layout::page_size_command(&d, spread, page, params) { + let _ = app.run(cmd, params); + } + }; // Reference point: W/H edits keep it in place. let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover()); if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) { @@ -498,21 +506,21 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let anchor = app.ui.ref_point.min(8); let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0); // The reference point's position: from the spine across, from the top down. - let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height); + let (x, y) = (pg.x - sp.spine_x() + fx * pg.width, fy * pg.height); egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { caption(ui, crate::i18n::tr(&lang, "X:")); // Pages don't move within their spread yet. ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0)); caption(ui, crate::i18n::tr(&lang, "W:")); if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor})); + resize(app, json!({"width": v, "anchor": anchor})); } ui.end_row(); caption(ui, crate::i18n::tr(&lang, "Y:")); ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0)); caption(ui, crate::i18n::tr(&lang, "H:")); if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) { - let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor})); + resize(app, json!({"height": v, "anchor": anchor})); } ui.end_row(); }); @@ -527,7 +535,7 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { for (name, w, h) in panels::properties::PAGE_PRESETS { if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(&lang, name))).clicked() { let (w, h) = if landscape { (h, w) } else { (w, h) }; - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + resize(app, json!({"width": w, "height": h, "anchor": anchor})); } } }, @@ -535,20 +543,23 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(&lang, tip)).clicked() && landscape != want { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": pg.height, "height": pg.width, "anchor": anchor})); + resize(app, json!({"width": pg.height, "height": pg.width, "anchor": anchor})); } } }); }); vsep(ui); - ui.vertical(|ui| { + ui.add_enabled_ui(n.is_some(), |ui| { caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); let label = panels::interactive::liquid_rule_label(pg.liquid); egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( ui, |ui| { for (r, l) in panels::interactive::LIQUID_RULES { - if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r { + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() + && pg.liquid != r + && let Some(n) = n + { let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]})); } } @@ -556,9 +567,11 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { ); }); vsep(ui); - ui.vertical(|ui| { + ui.add_enabled_ui(n.is_some(), |ui| { let mut overlay = pg.show_parent_overlay; - if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { + if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() + && let Some(n) = n + { let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); } }); @@ -1304,6 +1317,89 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + #[test] + fn parent_resize_properties_changes_the_parent_not_document_page_zero() { + parent_resize_panel(true); + } + + #[test] + fn parent_resize_control_changes_the_parent_not_document_page_zero() { + parent_resize_panel(false); + } + + fn parent_resize_panel(properties: bool) { + use egui_kittest::Harness; + { + let mut app = app(); + app.run("file.new", serde_json::json!({"width": 400, "height": 600, "pages": 3, "facingPages": true})).unwrap(); + app.run("layout.parents.new", serde_json::json!({"prefix": "B"})).unwrap(); + app.run("layout.pages.applyParent", serde_json::json!({"pages": [0], "parent": "A"})).unwrap(); + app.run("layout.parents.edit", serde_json::json!({"on": true, "parent": "A"})).unwrap(); + app.session.set_tool("page"); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0))); + *app.view_mut().unwrap() = crate::View { zoom: 1.0, origin: designcraft_geom::Point::new(-600.0, 0.0), fitted: true, rotation: 0 }; + let before = app.session.doc().unwrap().doc.clone(); + let mut h = Harness::builder().with_size(vec2(1600.0, 900.0)).build_ui_state( + move |ui, app: &mut DesignApp| { + let id = egui::Id::new("parent_resize_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + if properties { + panels::properties::show(app, ui); + } else { + control_page(app, ui); + } + }, + app, + ); + h.run_steps(3); + edit_parent_resize_field(&mut h, "33p4", "500 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!(d.parents[0].pages[0].width, 500.0, "the parent page in view must change"); + assert_eq!(d.spreads, before.spreads); + assert_eq!(d.parents[1], before.parents[1]); + let b = before.parents[1].id.0; + h.state_mut().run("layout.selectParentPage", json!({"parent": b, "page": 1})).unwrap(); + h.run_steps(3); + assert_eq!(crate::canvas::current_parent_page(h.state()), Some((1, 1))); + if properties { + h.state_mut().session.set_tool("selection"); + } + h.run_steps(3); + edit_parent_resize_field(&mut h, "50p0", "700 pt"); + edit_parent_resize_field(&mut h, "33p4", "560 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!((d.parents[1].pages[1].width, d.parents[1].pages[1].height), (560.0, 700.0)); + assert_eq!(d.parents[1].pages[0].width, 400.0); + assert_eq!(d.parents[0].pages[0].width, 500.0); + assert_eq!(d.spreads, before.spreads); + let parents = d.parents.clone(); + h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap(); + h.state_mut().session.set_tool("page"); + h.state_mut().run("layout.selectPage", json!({"page": 1})).unwrap(); + h.run_steps(3); + edit_parent_resize_field(&mut h, "33p4", "530 pt"); + let d = &h.state().session.doc().unwrap().doc; + assert_eq!(d.spreads[0].pages[0].width, 530.0); + assert_eq!(d.parents, parents); + } + } + + fn edit_parent_resize_field(h: &mut egui_kittest::Harness<'_, DesignApp>, value: &str, input: &str) { + h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some(value)) + .next() + .expect("page size field") + .click(); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(input.into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + } + #[test] fn the_page_tool_has_its_own_control_panel() { // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1cb7395c0..0714a93b2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -413,7 +413,12 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest } "ui.render" => { let Some(st) = app.session.active() else { return err("no document") }; - let page = match page_index(p, crate::canvas::current_page(app).unwrap_or(0), st.doc.page_count()) { + let current = match crate::canvas::current_page(app) { + Some(page) => page, + None if p.get("page").is_some() => 0, + None => return err("select a document page or give an explicit page"), + }; + let page = match page_index(p, current, st.doc.page_count()) { Ok(page) => page, Err(e) => return err(e), }; @@ -683,6 +688,20 @@ mod automation_contract_tests { assert_eq!(app.ui.palette.as_deref(), Some("field")); assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); } + #[test] + fn parent_resize_mode_does_not_default_document_actions_to_page_zero() { + let mut app = app(); + let ctx = egui::Context::default(); + app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); + app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + assert!(app.run("app.deletePage", json!({})).is_err()); + assert!(app.run("app.duplicateSpread", json!({})).is_err()); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true); + assert_eq!(app.session.doc().unwrap().doc, before); + } + #[test] fn control_pages_validate_before_writer_and_preserve_omission() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 40787954f..258eee509 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7695,6 +7695,36 @@ mod tests { } } + #[test] + fn parent_resize_exact_dialog_keeps_its_target_and_rejects_invalid_input() { + use designcraft_tools::{PointerEvent, PointerKind}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width":400,"height":600,"pages":3,"facingPages":true})).unwrap(); + app.run("layout.parents.new", json!({"prefix":"B"})).unwrap(); + app.run("layout.parents.edit", json!({"on":true})).unwrap(); + app.session.set_tool("page"); + let before = app.session.doc().unwrap().doc.clone(); + let sr = designcraft_doc::SpreadRef::Parent(1); + let p = app.session.layout().spread_page_rect(&before, sr, 1).unwrap().center(); + app.session.pointer(&PointerEvent::new(PointerKind::DoubleClick, p.x, p.y), designcraft_engine::ViewInfo::at_zoom(1.0)).unwrap(); + let request = app.session.ui_requests.pop().unwrap(); + let designcraft_engine::UiRequest::Dialog { id, params } = request else { panic!("expected exact-size dialog") }; + app.ui.dialog = Some(Dialog::new(&id, params)); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("width".into(), json!("530")); + d.fields.insert("height".into(), json!("710")); + confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!((doc.parents[1].pages[1].width, doc.parents[1].pages[1].height), (530.0, 710.0)); + assert_eq!(doc.parents[0], before.parents[0]); + assert_eq!(doc.spreads, before.spreads); + let saved = doc.clone(); + app.ui.dialog = Some(Dialog::new("cmd:layout.parentPageSize", json!({"parent":saved.parents[1].id.0,"page":1,"width":"NaN"}))); + assert!(confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + assert!(std::sync::Arc::ptr_eq(&saved, &app.session.doc().unwrap().doc)); + } + fn command_app(id: &str, fields: Value) -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 87537abd7..a50bdbdec 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1335,7 +1335,7 @@ fn run_ui_inner(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 Some(page) = crate::canvas::current_page(app) else { return Some(Err("select a document page".into())) }; let r = if id == "app.deletePage" { crate::dialogs::delete_pages(app, vec![page]) } else { diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index fd0094f9b..6f1c0e02e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -115,12 +115,11 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { return; } // The Page tool with no objects selected: the page's own settings, as InDesign's. - if info.is_none() && app.session.tool_id() == "page" { - let page = app.session.active().and_then(|st| st.selection.page).or_else(|| crate::canvas::current_page(app)); - if let Some(page) = page { - page_sections(app, ui, page); - return; + if info.is_none() && (app.session.tool_id() == "page" || app.session.active().is_some_and(|st| st.editing_parents)) { + if let Some((spread, page)) = crate::canvas::page_target(app) { + page_sections(app, ui, spread, page); } + return; } let Some(i) = info else { document_sections(app, ui); @@ -214,14 +213,13 @@ pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { /// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back, /// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and /// Quick Actions. Back returns to the Selection tool and the document's properties. -fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { +fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::SpreadRef, page: usize) { let u = units(app); let lang = app.ui.language.clone(); let Some(st) = app.session.active() else { return }; let d = st.doc.clone(); - let Some((si, pi)) = d.page_loc(page) else { return }; - let Some(pg) = d.spreads.get(si).and_then(|s| s.pages.get(pi)).cloned() else { return }; - let n = page + 1; + let Some(sp) = d.spread(spread) else { return }; + let Some(pg) = sp.pages.get(page).cloned() else { return }; let t = Tokens::get(ui.ctx()); // The band: "Page", and Back at its end. let band = block(ui, 33.0); @@ -245,13 +243,25 @@ fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { section(ui, crate::i18n::tr(&lang, "Page Size"), true); // Which page. let b = block(ui, FIELD_H); - let names: Vec = (0..d.page_count()).map(|i| app.session.page_label(i)).collect(); + let (names, selected): (Vec, usize) = match spread { + designcraft_doc::SpreadRef::Doc(si) => ((0..d.page_count()).map(|i| app.session.page_label(i)).collect(), d.first_page_of_spread(si) + page), + designcraft_doc::SpreadRef::Parent(_) => { + ((0..sp.pages.len()).map(|i| format!("{} · {}", sp.parent.as_ref().map(|p| p.label()).unwrap_or_default(), i + 1)).collect(), page) + } + }; let picked = place(ui, sub(b, 0.0, 0.0, 93.0, FIELD_H), |ui| { - widgets::dropdown_list(ui, names.get(page).map_or("", String::as_str), 93.0, &names, Some(page)) + widgets::dropdown_list(ui, names.get(selected).map_or("", String::as_str), 93.0, &names, Some(selected)) }); if let Some(p) = picked { - let _ = app.run("layout.selectPage", json!({"page": p + 1})); - crate::canvas::go_to_page(app, p); + match spread { + designcraft_doc::SpreadRef::Doc(_) => { + let _ = app.run("layout.selectPage", json!({"page": p + 1})); + crate::canvas::go_to_page(app, p); + } + designcraft_doc::SpreadRef::Parent(_) => { + let _ = app.run("layout.selectParentPage", json!({"parent": sp.id.0, "page": p})); + } + } return; } ui.add_space(3.0); @@ -293,13 +303,19 @@ fn page_sections(app: &mut DesignApp, ui: &mut Ui, page: usize) { size = Some(if key == "width" { (nv, h) } else { (w, nv) }); } } - if let Some((nw, nh)) = size { - let _ = app.run("layout.pageSize", json!({"pages": [n], "width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)})); + if let Some((nw, nh)) = size + && let Some((cmd, params)) = + designcraft_tools::layout::page_size_command(&d, spread, page, json!({"width": nw, "height": nh, "anchor": app.ui.ref_point.min(8)})) + { + let _ = app.run(cmd, params); } ui.add_space(6.0); let fw = full_width(ui); if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() { - crate::canvas::go_to_page(app, page); + match spread { + designcraft_doc::SpreadRef::Doc(si) => crate::canvas::go_to_page(app, d.first_page_of_spread(si) + page), + designcraft_doc::SpreadRef::Parent(_) => crate::canvas::show_spread(app, spread), + } crate::menus::activate(app, "layout.marginsAndColumns", &Value::Null); } divider(ui); diff --git a/docs/roadmap.md b/docs/roadmap.md index 9223ca701..7d471566f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DesignCraft roadmap (detail) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (.indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (independent parent page resizing, #1620; .indd and .indt open through indd-utils; text threads by hand; the gate waits on the Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026 (21.6) Forward-looking plan. The one-page summary is [ROADMAP.md](../ROADMAP.md); numbers are in [target-app-parity.md](target-app-parity.md); the work list is [gaps.md](gaps.md). Hours are @@ -17,7 +17,7 @@ but are next in line. | Core workflow | Works end to end? | Evidence | Hours to pass | |---|---|---|---| -| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, parent pages and overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | +| Set up a document (size, margins, columns, parent pages, page numbers, sections); save and reopen | yes | New Document, Document Setup, independently sized parent pages (Properties and Page Tool, #1620), parent overrides, Numbering & Section Options; `.designcraft` round-trip tests; recovery | 0 | | Flow a long story across frames and pages: thread, unthread, autoflow | **no** | threading by hand works (ports, loaded text cursor, `text.thread`/`text.unthread`/`text.removeFromThread`); Primary Text Frame autoflow adds pages users can't delete (#165) | 5–7 | | Format text with paragraph and character styles | partial (not blocking) | Paragraph/Character Style Options, Quick Apply, based-on, nested/GREP styles work; style edit/delete from the panel reported (#169); formatting set at a caret is discarded (#289) | 6–12 | | Place, crop and fit images; wrap text around them | yes | place gun, frame fitting and Fitting Options, content grabber, text wrap, Links panel | 0 | From b6f82deb11606d0022f428c77f36d6e4863d015f Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Fri, 23 Oct 2026 21:24:01 +0900 Subject: [PATCH 2/4] #1620: Restore Properties Back from parent editing --- crates/ui-egui/src/chrome.rs | 18 ++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5235180e0..d9d40e98a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1377,6 +1377,15 @@ mod tests { assert_eq!(d.parents[0].pages[0].width, 500.0); assert_eq!(d.spreads, before.spreads); let parents = d.parents.clone(); + if properties { + let saved = d.clone(); + h.get_by_label("Back").click(); + h.run_steps(3); + assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties"); + assert_eq!(h.state().session.tool_id(), "selection"); + assert!(h.query_by_label("Back").is_none(), "the next frame must leave the page properties screen"); + assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc)); + } h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap(); h.state_mut().session.set_tool("page"); h.state_mut().run("layout.selectPage", json!({"page": 1})).unwrap(); @@ -1385,6 +1394,15 @@ mod tests { let d = &h.state().session.doc().unwrap().doc; assert_eq!(d.spreads[0].pages[0].width, 530.0); assert_eq!(d.parents, parents); + if properties { + let saved = d.clone(); + h.get_by_label("Back").click(); + h.run_steps(3); + assert_eq!(h.state().session.tool_id(), "selection"); + assert!(h.query_by_label("Back").is_none()); + assert_eq!(h.state().session.doc().unwrap().selection.page, None); + assert!(std::sync::Arc::ptr_eq(&saved, &h.state().session.doc().unwrap().doc)); + } } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 6f1c0e02e..779e1e159 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -212,7 +212,7 @@ pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { /// Properties with the Page tool (Properties › Edit Page), as InDesign's: a "Page" band with Back, /// Page Size for this page alone (which page, preset, orientation, W/H), Margins and Columns and -/// Quick Actions. Back returns to the Selection tool and the document's properties. +/// Quick Actions. Back leaves parent editing and returns to the Selection tool and document properties. fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::SpreadRef, page: usize) { let u = units(app); let lang = app.ui.language.clone(); @@ -236,6 +236,9 @@ fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraft_doc::Spre }); rule(ui); if back { + if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) { + let _ = app.run("layout.parents.edit", json!({"on": false})); + } let _ = app.run("layout.selectPage", json!({"page": null})); app.select_tool("selection"); return; From 90bbec39d1a0b3cf9986541e4dc1d900b82a9632 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Sun, 1 Nov 2026 19:44:44 +0900 Subject: [PATCH 3/4] #1620: Restore parent margins and pre-canvas page defaults --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome/tests.rs | 14 ++- crates/ui-egui/src/control.rs | 79 ++++++++++-- crates/ui-egui/src/menus/run.rs | 6 +- .../ui-egui/src/panels/properties/document.rs | 115 ++++++++++-------- docs/control-protocol.md | 2 +- 6 files changed, 154 insertions(+), 64 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dbf91dc8e..b5c4a15e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,7 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. No readiness percentage change. +- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing remains available; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 73a943330..99704c1d7 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -913,7 +913,7 @@ fn caption_buttons_fit_the_minimum_window_width() { } #[test] -fn parent_resize_properties_changes_the_parent_not_document_page_zero() { +fn parent_resize_properties_keep_size_and_inline_parent_margins() { parent_resize_panel(true); } @@ -974,6 +974,18 @@ fn parent_resize_panel(properties: bool) { let parents = d.parents.clone(); if properties { let saved = d.clone(); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0")); + edit_parent_resize_field(&mut h, "3p0", "72 pt"); + let edited = &h.state().session.doc().unwrap().doc; + for page in &edited.parents[1].pages { + assert_eq!(page.margins, designcraft_doc::Margins { top: 72.0, bottom: 72.0, inside: 72.0, outside: 72.0 }); + } + assert_eq!(edited.parents[0], saved.parents[0]); + assert_eq!(edited.spreads, saved.spreads); + assert_eq!(edited.settings, saved.settings); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, saved); h.get_by_label("Back").click(); h.run_steps(3); assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7032f6810..d70a79695 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -421,7 +421,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest let Some(st) = app.session.active() else { return err("no document") }; let current = match crate::canvas::current_page(app) { Some(page) => page, - None if p.get("page").is_some() => 0, + None if !st.editing_parents || p.get("page").is_some() => 0, None => return err("select a document page or give an explicit page"), }; let page = match page_index(p, current, st.doc.page_count()) { @@ -695,19 +695,82 @@ mod automation_contract_tests { assert_eq!(app.session.execute("story.get", &json!({"story":f["story"]})).unwrap()["text"], "Hello"); } #[test] - fn parent_resize_mode_does_not_default_document_actions_to_page_zero() { + fn parent_resize_no_canvas_render_keeps_document_page_zero_default() { let mut app = app(); let ctx = egui::Context::default(); - app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))); - app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap(); + app.run("layout.pageSize", json!({"pages": [2], "width": 700})).unwrap(); + assert!(app.canvas_rect.is_none()); let before = app.session.doc().unwrap().doc.clone(); - assert!(app.run("app.deletePage", json!({})).is_err()); - assert!(app.run("app.duplicateSpread", json!({})).is_err()); - assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false); - assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true); + let omitted = call(&mut app, &ctx, "ui.render", json!({"scale": 0.1})); + assert_eq!(omitted["ok"], true, "{omitted}"); + let first = call(&mut app, &ctx, "ui.render", json!({"page": 0, "scale": 0.1})); + let second = call(&mut app, &ctx, "ui.render", json!({"page": 1, "scale": 0.1})); + assert_eq!(omitted, first); + assert_ne!(omitted, second); assert_eq!(app.session.doc().unwrap().doc, before); } + #[test] + fn parent_resize_no_canvas_delete_uses_first_document_page() { + let mut app = app(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + app.run("app.deletePage", json!({})).unwrap(); + let after = &app.session.doc().unwrap().doc; + assert_eq!(after.page_count(), 1); + assert_eq!(after.page(0).unwrap().id, before.page(1).unwrap().id); + assert_eq!(after.parents, before.parents); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn parent_resize_no_canvas_delete_keeps_the_object_warning() { + let mut app = app(); + app.run("frame.create", json!({"rect": [36, 36, 100, 100]})).unwrap(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + app.run("app.deletePage", json!({})).unwrap(); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!(dialog.id, "deletePages"); + assert_eq!(dialog.fields["pages"], json!([0])); + assert_eq!(app.session.doc().unwrap().doc, before); + } + + #[test] + fn parent_resize_no_canvas_duplicate_uses_first_document_spread() { + let mut app = app(); + assert!(app.canvas_rect.is_none()); + let before = app.session.doc().unwrap().doc.clone(); + let ids: Vec<_> = before.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).collect(); + let result = app.run("app.duplicateSpread", json!({})).unwrap(); + let after = &app.session.doc().unwrap().doc; + let first = result["page"].as_u64().unwrap() as usize; + assert_eq!(first, before.spreads[0].pages.len()); + assert_eq!(after.page_count(), before.page_count() + first); + for i in first..first * 2 { + assert!(!ids.contains(&after.page(i).unwrap().id)); + } + let retained: Vec<_> = after.spreads.iter().flat_map(|sp| sp.pages.iter().map(|p| p.id)).filter(|id| ids.contains(id)).collect(); + assert_eq!(retained, ids); + assert_eq!(after.parents, before.parents); + } + + #[test] + fn parent_resize_mode_does_not_default_document_actions_to_page_zero() { + for canvas in [None, Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0)))] { + let mut app = app(); + let ctx = egui::Context::default(); + app.canvas_rect = canvas; + app.run("layout.parents.edit", json!({"on":true,"parent":"A"})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + assert!(app.run("app.deletePage", json!({})).is_err()); + assert!(app.run("app.duplicateSpread", json!({})).is_err()); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"scale":0.1}))["ok"], false); + assert_eq!(call(&mut app, &ctx, "ui.render", json!({"page":0,"scale":0.1}))["ok"], true); + assert_eq!(app.session.doc().unwrap().doc, before); + } + } + #[test] fn control_pages_validate_before_writer_and_preserve_omission() { let mut app = app(); diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index d2a87be3c..f26532ebf 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -462,7 +462,11 @@ fn run_ui_part2(app: &mut DesignApp, id: &str, p: &Value) -> Option { let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; - let Some(page) = crate::canvas::current_page(app) else { return Some(Err("select a document page".into())) }; + let page = match crate::canvas::current_page(app) { + Some(page) => page, + None if !st.editing_parents => 0, + None => return Some(Err("select a document page".into())), + }; let r = if id == "app.deletePage" { crate::dialogs::delete_pages(app, vec![page]) } else { diff --git a/crates/ui-egui/src/panels/properties/document.rs b/crates/ui-egui/src/panels/properties/document.rs index f8ea15451..bb7111e48 100644 --- a/crates/ui-egui/src/panels/properties/document.rs +++ b/crates/ui-egui/src/panels/properties/document.rs @@ -136,6 +136,9 @@ pub(super) fn page_sections(app: &mut DesignApp, ui: &mut Ui, spread: designcraf { let _ = app.run(cmd, params); } + if matches!(spread, designcraft_doc::SpreadRef::Parent(_)) { + margin_section(app, ui); + } ui.add_space(6.0); let fw = full_width(ui); if outline_button(ui, crate::i18n::tr(&lang, "Margins and Columns"), fw).clicked() { @@ -201,6 +204,65 @@ fn page_box_field(app: &mut DesignApp, ui: &mut Ui, b: Rect) { } } +fn margin_section(app: &mut DesignApp, ui: &mut Ui) { + let u = units(app); + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let t = Tokens::get(ui.ctx()); + section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true); + let cur = crate::canvas::current_page(app).unwrap_or(0); + // While parent spreads are edited the fields belong to the parent page in view; they used to + // show (and change) the document pages instead, so nothing changed on the parent. + let parent_target = crate::canvas::current_parent_page(app); + let shown = match parent_target { + Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(), + None => d.page(cur).cloned(), + }; + if let Some(p) = shown { + let linked = temp_bool(ui, "margins_linked", true); + let b = block(ui, 2.0 * FIELD_H + 5.0); + let mut set: Option = None; + let fields = [ + ("margin-top", "Top", p.margins.top, 0.0, 0.0), + ("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0), + ("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0), + ("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0), + ]; + for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() { + let ir = sub(b, x, y + 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, icon, t.icon); + ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| { + NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui) + }); + if let Some(nv) = nv { + set = Some(edited_margins(p.margins, linked, k, nv)); + } + } + let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H); + let clicked = place(ui, lr, |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0)) + .clicked() + }); + if clicked { + set_temp_bool(ui, "margins_linked", !linked); + } + if let Some(m) = set { + let params = match parent_target { + // Both pages of a facing parent spread, so the margins show wherever the parent is used. + Some((pi, _)) => { + let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len()); + json!({"margins": m, "parent": pi, "pages": (0..n).collect::>()}) + } + None => json!({"margins": m}), + }; + let _ = app.run("layout.marginsAndColumns", params); + } + } +} + pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { let u = units(app); let Some(st) = app.session.active() else { return }; @@ -254,59 +316,8 @@ pub(super) fn document_sections(app: &mut DesignApp, ui: &mut Ui) { if changed { let _ = app.run("layout.documentSetup", json!({"facingPages": facing})); } - // Margins. - section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true); + margin_section(app, ui); let cur = crate::canvas::current_page(app).unwrap_or(0); - // While parent spreads are edited the fields belong to the parent page in view; they used to - // show (and change) the document pages instead, so nothing changed on the parent. - let parent_target = crate::canvas::current_parent_page(app); - let shown = match parent_target { - Some((pi, qi)) => d.parents.get(pi).and_then(|s| s.pages.get(qi)).cloned(), - None => d.page(cur).cloned(), - }; - if let Some(p) = shown { - let linked = temp_bool(ui, "margins_linked", true); - let b = block(ui, 2.0 * FIELD_H + 5.0); - let mut set: Option = None; - let fields = [ - ("margin-top", "Top", p.margins.top, 0.0, 0.0), - ("margin-bottom", "Bottom", p.margins.bottom, 0.0, 26.0), - ("margin-outside", if d.settings.facing_pages { "Outside" } else { "Right" }, p.margins.outside, 127.0, 0.0), - ("margin-inside", if d.settings.facing_pages { "Inside" } else { "Left" }, p.margins.inside, 127.0, 26.0), - ]; - for (k, (icon, tip, v, x, y)) in fields.into_iter().enumerate() { - let ir = sub(b, x, y + 2.0, 17.0, 17.0); - icons::paint(ui.painter(), ir, icon, t.icon); - ui.interact(ir, ui.scope_id().with(("micon", k)), Sense::hover()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip)); - }); - let nv = place(ui, sub(b, x + 20.0, y, 73.0, FIELD_H), |ui| { - NumField::measure(tip, Some(v), u).width(73.0).spinner().range(0.0, 8000.0).show(ui) - }); - if let Some(nv) = nv { - set = Some(edited_margins(p.margins, linked, k, nv)); - } - } - let lr = sub(b, 99.0, 12.5, 22.0, FIELD_H); - let clicked = place(ui, lr, |ui| { - widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, "Make all settings the same", vec2(22.0, 21.0)) - .clicked() - }); - if clicked { - set_temp_bool(ui, "margins_linked", !linked); - } - if let Some(m) = set { - let params = match parent_target { - // Both pages of a facing parent spread, so the margins show wherever the parent is used. - Some((pi, _)) => { - let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len()); - json!({"margins": m, "parent": pi, "pages": (0..n).collect::>()}) - } - None => json!({"margins": m}), - }; - let _ = app.run("layout.marginsAndColumns", params); - } - } ui.add_space(6.0); let fw = full_width(ui); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Adjust Layout"), fw).clicked() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2d8fb7718..e886c07ab 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -49,7 +49,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window | -| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a document page headlessly (PNG; base64 if no path); parent editing mode requires an explicit 0-based `page` | +| `ui.render` | `{path?, page?, scale?, bleed?}` | Render a document page headlessly (PNG; base64 if no path); without a canvas, document mode defaults to page 0; parent editing mode requires an explicit 0-based `page` | | `app.open` / `app.save` / `app.export` | | Files | | `app.quit` | | Quit without asking about unsaved changes (the `app.quit` command, like ⌘Q, asks) | From 20925111eb5701e2b31d965446ec6086821d1885 Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Mon, 2 Nov 2026 00:05:23 +0900 Subject: [PATCH 4/4] #1620: Limit inline margins to the selected parent page --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome/tests.rs | 55 +++++++++++++++---- .../ui-egui/src/panels/properties/document.rs | 6 +- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b5c4a15e2..3523a83be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -388,7 +388,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing remains available; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. +- **2026-11-01** · Parent page resizing (#1620): Properties, the Page Tool Control panel and on-page handles resize the selected parent page independently of document pages. Stable parent IDs, repeated resizing, anchors and liquid rules are preserved on the current command modules. Inline parent margin editing targets only the selected parent page and preserves opposite-page margins and document overrides; document commands retain their first-page default before the canvas is initialized. No readiness percentage change. - **2026-10-30** · Import warnings about a story have a Show button: it closes the warnings (the status line keeps them) and selects the story's first frame, fitted in the window. `file.open` returns `warningItems`, the frame each IDML or InDesign warning is about (or null), matching the converter's INDD story numbers (`story 30892`, written to IDML as `u78ac`) and IDML `Self` names to the imported stories. No readiness percentage change. - **2026-10-30** · Parent pages: a running-header variable shows its name in angle brackets on the parent itself (it was blank), as InDesign shows it; on the document pages, and in text with no page, it shows its text as before. No readiness percentage change. diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 99704c1d7..f0449c2d0 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -929,6 +929,16 @@ fn parent_resize_panel(properties: bool) { app.run("file.new", serde_json::json!({"width": 400, "height": 600, "pages": 3, "facingPages": true})).unwrap(); app.run("layout.parents.new", serde_json::json!({"prefix": "B"})).unwrap(); app.run("layout.pages.applyParent", serde_json::json!({"pages": [0], "parent": "A"})).unwrap(); + if properties { + app.run("layout.marginsAndColumns", json!({"parent": 1, "pages": [0], "margins": 24})).unwrap(); + app.run( + "layout.marginsAndColumns", + json!({"parent": 1, "pages": [1], "margins": {"top": 36, "bottom": 48, "inside": 60, "outside": 72}}), + ) + .unwrap(); + app.run("layout.pages.applyParent", json!({"pages": [0, 1, 2], "parent": "B"})).unwrap(); + app.run("layout.marginsAndColumns", json!({"pages": [0], "margins": 90})).unwrap(); + } app.run("layout.parents.edit", serde_json::json!({"on": true, "parent": "A"})).unwrap(); app.session.set_tool("page"); app.canvas_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0))); @@ -974,18 +984,7 @@ fn parent_resize_panel(properties: bool) { let parents = d.parents.clone(); if properties { let saved = d.clone(); - assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0")); - edit_parent_resize_field(&mut h, "3p0", "72 pt"); - let edited = &h.state().session.doc().unwrap().doc; - for page in &edited.parents[1].pages { - assert_eq!(page.margins, designcraft_doc::Margins { top: 72.0, bottom: 72.0, inside: 72.0, outside: 72.0 }); - } - assert_eq!(edited.parents[0], saved.parents[0]); - assert_eq!(edited.spreads, saved.spreads); - assert_eq!(edited.settings, saved.settings); - h.state_mut().run("edit.undo", json!({})).unwrap(); - h.run_steps(3); - assert_eq!(h.state().session.doc().unwrap().doc, saved); + check_parent_margin_edits(&mut h); h.get_by_label("Back").click(); h.run_steps(3); assert!(!h.state().session.doc().unwrap().editing_parents, "Back returns to document properties"); @@ -1013,6 +1012,38 @@ fn parent_resize_panel(properties: bool) { } } +fn check_parent_margin_edits(h: &mut egui_kittest::Harness<'_, DesignApp>) { + let saved = h.state().session.doc().unwrap().doc.clone(); + assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Top").value().as_deref(), Some("3p0")); + edit_parent_resize_field(h, "3p0", "72 pt"); + let edited = &h.state().session.doc().unwrap().doc; + assert_eq!(edited.parents[1].pages[1].margins, designcraft_doc::Margins::uniform(72.0)); + assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]); + assert_eq!(edited.parents[0], saved.parents[0]); + assert_eq!(edited.page(0), saved.page(0), "local document margins stay unchanged"); + assert_eq!(edited.page(1), saved.page(1), "the other parent page's inheritor stays unchanged"); + assert_eq!(edited.page(2).unwrap().margins, designcraft_doc::Margins::uniform(72.0)); + assert_eq!(edited.settings, saved.settings); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, saved); + h.get_by_label("Make all settings the same").click(); + h.run_steps(3); + edit_parent_resize_field(h, "3p0", "84 pt"); + let edited = &h.state().session.doc().unwrap().doc; + let margins = designcraft_doc::Margins { top: 84.0, bottom: 48.0, inside: 60.0, outside: 72.0 }; + assert_eq!(edited.parents[1].pages[1].margins, margins); + assert_eq!(edited.parents[1].pages[0], saved.parents[1].pages[0]); + assert_eq!(edited.parents[0], saved.parents[0]); + assert_eq!(edited.page(0), saved.page(0)); + assert_eq!(edited.page(1), saved.page(1)); + assert_eq!(edited.page(2).unwrap().margins, margins); + assert_eq!(edited.settings, saved.settings); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().doc, saved); +} + fn edit_parent_resize_field(h: &mut egui_kittest::Harness<'_, DesignApp>, value: &str, input: &str) { h.query_all_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some(value)).next().expect("page size field").click(); h.run_steps(2); diff --git a/crates/ui-egui/src/panels/properties/document.rs b/crates/ui-egui/src/panels/properties/document.rs index bb7111e48..450123568 100644 --- a/crates/ui-egui/src/panels/properties/document.rs +++ b/crates/ui-egui/src/panels/properties/document.rs @@ -251,11 +251,7 @@ fn margin_section(app: &mut DesignApp, ui: &mut Ui) { } if let Some(m) = set { let params = match parent_target { - // Both pages of a facing parent spread, so the margins show wherever the parent is used. - Some((pi, _)) => { - let n = app.session.active().and_then(|st| st.doc.parents.get(pi)).map_or(1, |sp| sp.pages.len()); - json!({"margins": m, "parent": pi, "pages": (0..n).collect::>()}) - } + Some((pi, qi)) => json!({"margins": m, "parent": pi, "pages": [qi]}), None => json!({"margins": m}), }; let _ = app.run("layout.marginsAndColumns", params);