Layout: parent pages pass margins and guides to their pages

This commit is contained in:
aiongg
2026-10-11 13:02:23 +00:00
parent 16a7365701
commit 470aac7d10
12 changed files with 316 additions and 31 deletions
+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-11:** Document pages take their parent page's margins and columns (left or right parent page by side) and follow its changes unless they have their own; parent ruler guides draw and snap on the pages based on it. IDML import marks a page margin preference that differs from its parent as the page's own. No readiness percentage change.
- **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change.
- **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI
smoke-tested under QEMU).
+79 -4
View File
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::ids::{ItemId, LayerId, PageId, SpreadId};
use crate::item::Item;
use crate::page::{Columns, Margins, Page, PageSide, ParentInfo, Section, Spread};
use crate::page::{Columns, Guide, Margins, Orientation, Page, PageSide, ParentInfo, Section, Spread};
use crate::styles::Styles;
use crate::{DataMerge, DocError, DocSettings, Document, Intent, LAYER_COLORS, Layer, ParaFormat, Result, SpreadRef};
@@ -218,6 +218,7 @@ impl Document {
x: 0.0,
margins,
columns: Columns { count: columns.max(1), gutter, positions: None },
local_margins: false,
parent,
side: PageSide::Single,
overridden: vec![],
@@ -365,6 +366,8 @@ impl Document {
out.insert(at, sp);
}
self.spreads = out;
// Pages can change sides, and so parent pages.
self.sync_parent_margins();
}
/// Insert `count` pages after absolute page `after` (None = at the start), using `parent`.
@@ -540,18 +543,77 @@ impl Document {
let (si, pi) = self.page_loc(abs).ok_or(DocError::NoPage(abs))?;
Arc::make_mut(&mut self.spreads[si]).pages[pi].parent = parent;
}
self.sync_parent_margins();
Ok(())
}
/// Parent page (spread index, page index) to show behind document page `abs`.
pub fn parent_page_for(&self, abs: usize) -> Option<(usize, usize)> {
let page = self.page(abs)?;
self.parent_page_of(self.page(abs)?)
}
/// Parent page (spread index, page index) applied to `page`: the page on its side of its
/// parent spread.
pub fn parent_page_of(&self, page: &Page) -> Option<(usize, usize)> {
let pi = self.parent_index(page.parent?)?;
let ps = &self.parents[pi];
// A parent spread can come without pages from a file.
let idx = if ps.pages.len() >= 2 && page.side == PageSide::Left { 0 } else { ps.pages.len().checked_sub(1)? };
let idx = self.parents.get(pi)?.parent_page_index(page.side)?;
Some((pi, idx))
}
/// The parent page applied to `page`.
pub fn parent_page(&self, page: &Page) -> Option<&Page> {
let (pi, qi) = self.parent_page_of(page)?;
self.parents.get(pi)?.pages.get(qi)
}
/// Ruler guides `page` shows from its parent page, moved from the parent page's place in its
/// spread to `page`'s place in its own. They stay stored on the parent page.
pub fn parent_guides<'a>(&'a self, page: &Page) -> impl Iterator<Item = Guide> + 'a {
let src = self.parent_page(page);
let dx = src.map_or(0.0, |q| page.x - q.x);
src.into_iter().flat_map(|q| q.guides.iter()).map(move |g| {
let mut g = g.clone();
if g.orientation == Orientation::Vertical {
g.position += dx;
}
g
})
}
/// Give every document page without local margins its parent page's margins and columns.
pub fn sync_parent_margins(&mut self) {
for si in 0..self.spreads.len() {
for pi in 0..self.spreads[si].pages.len() {
let Some(page) = self.spreads[si].pages.get(pi) else { continue };
if page.local_margins {
continue;
}
let Some(src) = self.parent_page(page).filter(|q| !q.same_margins(page)).map(|q| (q.margins, q.columns.clone())) else {
continue;
};
if let Some(page) = Arc::make_mut(&mut self.spreads[si]).pages.get_mut(pi) {
(page.margins, page.columns) = src;
}
}
}
}
/// Mark document pages whose margins or columns differ from their parent page's as local, so
/// that a later sync keeps them (documents saved before pages tracked local margins).
pub fn mark_differing_margins_local(&mut self) {
for si in 0..self.spreads.len() {
for pi in 0..self.spreads[si].pages.len() {
let Some(page) = self.spreads[si].pages.get(pi) else { continue };
if page.local_margins || self.parent_page(page).is_none_or(|q| q.same_margins(page)) {
continue;
}
if let Some(page) = Arc::make_mut(&mut self.spreads[si]).pages.get_mut(pi) {
page.local_margins = true;
}
}
}
}
}
#[cfg(test)]
@@ -584,6 +646,19 @@ mod tests {
assert_eq!(d.parent_page_for(1), None);
}
/// Files saved before pages tracked local margins: pages that differ from their parent keep
/// their margins; the rest follow the parent.
#[test]
fn differing_margins_from_older_files_stay_local() {
let mut d = Document::new(&NewDocument { pages: 2, ..Default::default() });
Arc::make_mut(&mut d.spreads[1]).pages[0].margins = Margins::uniform(90.0);
d.mark_differing_margins_local();
Arc::make_mut(&mut d.parents[0]).pages.iter_mut().for_each(|p| p.margins = Margins::uniform(12.0));
d.sync_parent_margins();
assert_eq!(d.page(0).map(|p| (p.margins.top, p.local_margins)), Some((12.0, false)));
assert_eq!(d.page(1).map(|p| (p.margins.top, p.local_margins)), Some((90.0, true)));
}
#[test]
fn non_facing_one_page_per_spread() {
let d = Document::new(&NewDocument { pages: 3, facing_pages: false, ..Default::default() });
+14
View File
@@ -98,6 +98,10 @@ pub struct Page {
pub x: f64,
pub margins: Margins,
pub columns: Columns,
/// Margins and columns set on this page itself. Without them a document page takes its
/// parent page's margins and columns and follows their changes.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub local_margins: bool,
/// Applied parent spread (`None` = [None]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<SpreadId>,
@@ -224,6 +228,10 @@ impl Page {
pub fn bounds(&self) -> Rect {
Rect::new(self.x, 0.0, self.x + self.width, self.height)
}
/// Same margins and columns as `other`.
pub fn same_margins(&self, other: &Page) -> bool {
self.margins == other.margins && self.columns == other.columns
}
/// Left and right margins resolved for the page side (inside is at the spine).
pub fn left_right_margins(&self) -> (f64, f64) {
match self.side {
@@ -296,6 +304,11 @@ impl Spread {
None => self.bounds().center().x,
}
}
/// Index of this parent spread's page shown behind a document page on `side`: the left page
/// for left pages, else the last page. `None` for a parent spread without pages.
pub fn parent_page_index(&self, side: PageSide) -> Option<usize> {
if self.pages.len() >= 2 && side == PageSide::Left { Some(0) } else { self.pages.len().checked_sub(1) }
}
/// Index of the page under spread x (nearest page for the pasteboard).
pub fn page_at_x(&self, x: f64) -> Option<usize> {
if self.pages.is_empty() {
@@ -359,6 +372,7 @@ mod tests {
x,
margins: Margins { top: 36.0, bottom: 36.0, inside: 54.0, outside: 36.0 },
columns: Columns { count: 2, gutter: 12.0, positions: None },
local_margins: false,
parent: None,
side,
overridden: vec![],
+1
View File
@@ -421,6 +421,7 @@ fn assign_page(dst: &mut Document, abs: usize, from: &Page) {
p.parent = from.parent;
p.margins = from.margins;
p.columns = from.columns.clone();
p.local_margins = from.local_margins;
p.width = from.width;
p.height = from.height;
if size_changed {
+102 -16
View File
@@ -191,33 +191,37 @@ pub fn specs() -> Vec<CommandSpec> {
"Margins and Columns…",
["Layout"],
None,
"{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins)}",
"{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins)} — document pages take these as their own margins; with `parent` (a parent's prefix, label or index) the parent's pages change instead (`pages` then indexes them), and so do the document pages without their own",
has_doc,
|s, p| {
let pages: Option<Vec<usize>> =
p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect());
let parent = match p.get("parent") {
None | Some(Value::Null) => None,
Some(v) => Some(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad("layout.marginsAndColumns", "no such parent"))?),
};
let p = p.clone();
let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false);
s.edit(|d, _| {
let before = adjust.then(|| margin_boxes(d));
let all: Vec<usize> = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect());
for abs in all {
let Some((si, pi)) = d.page_loc(abs) else { continue };
let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi];
match p.get("margins") {
Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
if let Ok(m) = serde_json::from_value(v.clone()) {
pg.margins = m;
if let Some(pi) = parent {
let n = d.parents.get(pi).map_or(0, |sp| sp.pages.len());
let sp = d.parents.get_mut(pi).map(std::sync::Arc::make_mut);
if let Some(sp) = sp {
for qi in pages.clone().unwrap_or_else(|| (0..n).collect()) {
if let Some(pg) = sp.pages.get_mut(qi) {
set_margins_columns(pg, &p);
}
}
_ => {}
}
if let Some(c) = p.get("columns").and_then(Value::as_u64) {
pg.columns.count = (c as u32).clamp(1, 216);
}
if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
pg.columns.gutter = g.max(0.0);
d.sync_parent_margins();
} else {
let all: Vec<usize> = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect());
for abs in all {
let Some((si, pi)) = d.page_loc(abs) else { continue };
let Some(pg) = d.spreads.get_mut(si).map(std::sync::Arc::make_mut).and_then(|sp| sp.pages.get_mut(pi)) else { continue };
set_margins_columns(pg, &p);
pg.local_margins = true;
}
}
if let Some(b) = before {
@@ -610,6 +614,34 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// `"A"` → that parent's id; `null` → Some(None) (= [None]); missing → None (use default).
/// Apply the `margins`, `columns` and `gutter` params of `layout.marginsAndColumns` to a page.
fn set_margins_columns(pg: &mut designcraft_doc::Page, p: &Value) {
match p.get("margins") {
Some(Value::Number(n)) => pg.margins = Margins::uniform(n.as_f64().unwrap_or(36.0)),
Some(v @ Value::Object(_)) => {
if let Ok(m) = serde_json::from_value(v.clone()) {
pg.margins = m;
}
}
_ => {}
}
if let Some(c) = p.get("columns").and_then(Value::as_u64) {
pg.columns.count = (c as u32).clamp(1, 216);
}
if let Some(g) = p.get("gutter").and_then(Value::as_f64) {
pg.columns.gutter = g.max(0.0);
}
}
/// A parent spread named by prefix, label (`A-Parent`) or index.
fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option<usize> {
match v {
Value::Number(n) => n.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < d.parents.len()),
Value::String(s) => d.parents.iter().position(|p| p.parent.as_ref().is_some_and(|i| i.prefix == *s || i.label() == *s)),
_ => None,
}
}
fn parent_ref(d: &designcraft_doc::Document, v: Option<&Value>) -> Option<Option<SpreadId>> {
match v? {
Value::Null => Some(None),
@@ -921,3 +953,57 @@ mod adjust_tests {
assert_eq!(b, b2);
}
}
#[cfg(test)]
mod parent_margin_tests {
use designcraft_doc::{Document, Margins};
use serde_json::json;
use crate::Session;
fn margins(d: &Document) -> Vec<(Margins, u32, bool)> {
(0..d.page_count()).filter_map(|i| d.page(i)).map(|p| (p.margins, p.columns.count, p.local_margins)).collect()
}
#[test]
fn pages_follow_their_parent_pages_margins_unless_set_locally() {
let mut s = Session::new();
// Facing: [1] [2 3]; page 3 gets its own margins.
s.execute("file.new", &json!({"pages": 3})).unwrap();
s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap();
// A's left page: only the left document page follows.
let left = json!({"top": 10, "bottom": 20, "inside": 30, "outside": 40});
s.execute("layout.marginsAndColumns", &json!({"parent": "A", "pages": [0], "margins": left, "columns": 2})).unwrap();
let l = Margins { top: 10.0, bottom: 20.0, inside: 30.0, outside: 40.0 };
let m = margins(&s.doc().unwrap().doc);
assert_eq!(m, vec![(Margins::uniform(36.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]);
// A's right page: page 1 follows, page 3 keeps its own.
s.execute("layout.marginsAndColumns", &json!({"parent": "A-Parent", "pages": [1], "margins": 50})).unwrap();
let m = margins(&s.doc().unwrap().doc);
assert_eq!(m, vec![(Margins::uniform(50.0), 1, false), (l, 2, false), (Margins::uniform(100.0), 1, true)]);
// A page inserted after page 1 pushes page 2 to the right: it takes the right parent page's.
s.execute("layout.pages.insert", &json!({"after": 0, "count": 1, "parent": "A"})).unwrap();
let m = margins(&s.doc().unwrap().doc);
assert_eq!(m[1], (l, 2, false), "the new left page");
assert_eq!(m[2], (Margins::uniform(50.0), 1, false), "old page 2, now a right page");
assert_eq!(m[3], (Margins::uniform(100.0), 1, true));
assert!(s.execute("layout.marginsAndColumns", &json!({"parent": "Z", "margins": 10})).is_err());
}
#[test]
fn parent_guides_show_on_pages_but_stay_on_the_parent() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2})).unwrap();
// A vertical guide 100 pt into A's right page (x = 612 in the parent spread).
s.execute("guide.add", &json!({"spread": {"kind": "parent", "index": 0}, "page": 1, "orientation": "vertical", "position": 712})).unwrap();
let d = &s.doc().unwrap().doc;
let right = d.page(0).unwrap();
let shown: Vec<f64> = d.parent_guides(right).map(|g| g.position).collect();
assert_eq!(shown, vec![100.0], "moved to the document page's place");
assert_eq!(d.parent_guides(d.page(1).unwrap()).count(), 0, "the left page shows A's left page's guides");
// Not the document page's own: not listed, moved or deleted from there.
assert_eq!(s.execute("guide.list", &json!({"spread": 0})).unwrap(), json!([]));
assert!(s.execute("guide.move", &json!({"spread": 0, "page": 0, "index": 0, "position": 50})).is_err());
assert!(s.execute("guide.delete", &json!({"spread": 0, "page": 0, "index": 0})).is_err());
}
}
+19
View File
@@ -251,3 +251,22 @@ fn named_lists_round_trip_through_idml() {
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
}
#[test]
fn parent_and_page_margins_round_trip_through_idml() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 3})).unwrap();
s.execute("layout.marginsAndColumns", &json!({"parent": "A", "margins": 48, "columns": 2})).unwrap();
s.execute("layout.marginsAndColumns", &json!({"pages": [2], "margins": 100})).unwrap();
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&s.doc().unwrap().doc)).unwrap();
let pages: Vec<(f64, u32, bool)> =
(0..back.page_count()).filter_map(|i| back.page(i)).map(|p| (p.margins.top, p.columns.count, p.local_margins)).collect();
assert_eq!(pages, vec![(48.0, 2, false), (48.0, 2, false), (100.0, 2, true)]);
assert!(back.parents[0].pages.iter().all(|p| p.margins.top == 48.0 && p.columns.count == 2));
// The imported pages still follow their parent.
let mut d = back;
std::sync::Arc::make_mut(&mut d.parents[0]).pages[1].margins.top = 60.0;
d.sync_parent_margins();
assert_eq!(d.page(0).unwrap().margins.top, 60.0);
assert_eq!(d.page(2).unwrap().margins.top, 100.0);
}
+2
View File
@@ -107,6 +107,7 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
Arc::make_mut(a).data = Arc::new(data);
}
}
doc.mark_differing_margins_local();
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
@@ -124,6 +125,7 @@ fn load_legacy_json(bytes: &[u8]) -> Result<Document, FormatError> {
}
}
}
doc.mark_differing_margins_local();
doc.check().map_err(|e| FormatError::NotOurs(e.to_string()))?;
Ok(doc)
}
+17 -4
View File
@@ -1852,10 +1852,6 @@ impl<'r> Importer<'r> {
{
self.page_index.insert(s.to_string(), first_abs + i);
}
let (margins, columns) = match p.find("MarginPreference") {
Some(m) => margins_of(m),
None => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())),
};
let side = if !facing {
PageSide::Single
} else if r.x1 <= 1e-6 {
@@ -1864,6 +1860,22 @@ impl<'r> Importer<'r> {
PageSide::Right
};
let parent = p.get("AppliedMaster").and_then(|m| self.parent_ids.get(m)).copied();
// A document page's margins are local when they differ from its parent page's; a
// page without its own takes the parent page's.
let from_parent = if master {
None
} else {
parent
.and_then(|id| self.parents.iter().find(|sp| sp.id == id))
.and_then(|sp| sp.pages.get(sp.parent_page_index(side)?))
.map(|q| (q.margins, q.columns.clone()))
};
let (margins, columns) = match (p.find("MarginPreference"), &from_parent) {
(Some(m), _) => margins_of(m),
(None, Some(own)) => own.clone(),
(None, None) => self.default_margins.clone().unwrap_or((Margins::uniform(36.0), Columns::default())),
};
let local_margins = from_parent.is_some_and(|(m, c)| m != margins || c != columns);
out_pages.push(Page {
id: pid,
width: r.width(),
@@ -1871,6 +1883,7 @@ impl<'r> Importer<'r> {
x: r.x0 - min_x,
margins,
columns,
local_margins,
parent,
side,
overridden: vec![],
+30 -3
View File
@@ -1072,8 +1072,8 @@ fn push_parent_length_chain(
/// Ruler guides, margins, and column sides. No overlay.
///
/// A page guide attracts when the moving rect overlaps that page on the other axis. A spread
/// guide uses the spread bounds. Locked guides are included. A guide is a target only when
/// `Guide::visible_in` accepts it.
/// guide uses the spread bounds. Locked guides are included, and so are the guides a page shows
/// from its parent page. A guide is a target only when `Guide::visible_in` accepts it.
fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) -> Option<AxisHit> {
if !cx.snap.snap_to_guides || !cx.snap.show_guides {
return None;
@@ -1085,7 +1085,8 @@ fn pass_guides(cx: &ToolContext, req: &SnapRequest<'_>, axis: Axis, tol: f64) ->
let mut best: Option<Best> = None;
for page in &sp.pages {
let page_bounds = page.bounds();
for guide in &page.guides {
// The page's own guides, then those it shows from its parent page.
for guide in page.guides.iter().cloned().chain(cx.doc.parent_guides(page)) {
if !guide.visible_in(cx.doc) || !guide.position.is_finite() || guide.orientation != axis_orientation(axis) {
continue;
}
@@ -1698,6 +1699,32 @@ mod tests {
assert!((hit.delta.x - -1.0).abs() < 1e-6, "delta.x = {}", hit.delta.x);
}
#[test]
fn parent_page_guides_snap_on_the_document_page() {
// Facing document: page 1 is a right page at x = 0; its parent page is A's right page at
// x = 612 in the parent spread.
let mut doc = Document::new(&NewDocument::default());
let layer = doc.default_layer();
let id = ItemId(doc.alloc());
let item = Item::new(id, layer, Shape::Rectangle, shapes::rectangle(Rect::new(80.0, 100.0, 100.5, 140.0)));
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
Arc::make_mut(&mut doc.parents[0]).pages[1].guides.push(Guide {
orientation: Orientation::Vertical,
position: 612.0 + 104.0,
spread: false,
locked: false,
layer: None,
liquid: false,
});
let cache = Cache::new();
let layout = CanvasLayout::new(&doc, false);
let sel = Selection::default();
let mut cx = ctx_on(&doc, &sel, &cache, &layout);
cx.snap.zone_px = 4.0;
let hit = snap(&cx, move_req(Rect::new(80.0, 100.0, 100.5, 140.0), &[id]));
assert!((hit.delta.x - 3.5).abs() < 1e-6, "snaps to the parent guide at x = 104: {:?}", hit.delta);
}
#[test]
fn grid_beats_a_closer_guide() {
let mut doc = Document::new(&NewDocument::default());
+38 -1
View File
@@ -153,6 +153,22 @@ pub fn current_page(app: &DesignApp) -> Option<usize> {
Some(st.doc.first_page_of_spread(si) + pi)
}
/// While parent spreads are shown: (parent index, page index) under the view centre, or `None`
/// when the centre is on a document spread.
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 slot = layout.slots.get(current_slot(app, &layout)?)?;
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));
Some((pi, st.doc.parents.get(pi)?.page_at_x(c.x)?))
}
/// Scroll so absolute page `abs` is centred (keeps zoom).
pub fn go_to_page(app: &mut DesignApp, abs: usize) {
let Some(st) = app.session.active() else { return };
@@ -634,7 +650,8 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document
}
}
}
for g in p.guides.iter().filter(|g| g.visible_in(doc)) {
// The page's own guides, then those it shows from its parent page.
for g in p.guides.iter().cloned().chain(doc.parent_guides(p)).filter(|g| g.visible_in(doc)) {
// Spread guides cross the pasteboard; page guides their page.
let span = if g.spread { pasteboard } else { pr };
let (a, bb) = match g.orientation {
@@ -1772,6 +1789,26 @@ mod tests {
(s.horizontal_units, s.vertical_units)
}
#[test]
fn parent_guides_on_a_page_are_not_picked_there() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
// 100 pt into A's right page, which page 1 (a right page at x = 0) shows.
let parent_guide = json!({"spread": {"kind": "parent", "index": 0}, "page": 1, "orientation": "vertical", "position": 712});
app.run("guide.add", parent_guide).unwrap();
let mut h = harness(app);
let at = |app: &DesignApp| {
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
let layout = CanvasLayout::new(&app.session.active().unwrap().doc, false);
let slot = layout.slots.iter().find(|s| s.spread == SpreadRef::Doc(0)).unwrap();
guide_at(app, &xf, xf.to_screen(slot.xf * Point::new(100.0, 300.0)))
};
assert_eq!(at(h.state()), None, "the parent's guide is edited on the parent");
h.state_mut().run("guide.add", json!({"orientation": "vertical", "position": 100})).unwrap();
h.run_steps(2);
assert!(matches!(at(h.state()), Some((SpreadRef::Doc(0), 0, 0, _))), "the page's own guide is");
}
#[test]
fn right_clicking_a_ruler_sets_that_rulers_units() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+12 -2
View File
@@ -246,7 +246,13 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) {
// Margins.
section(ui, crate::i18n::tr(&app.ui.language, "Margins"), true);
let cur = crate::canvas::current_page(app).unwrap_or(0);
if let Some(p) = d.page(cur).cloned() {
// On a parent spread its page's margins, which the document pages based on it follow.
let parent = crate::canvas::current_parent_page(app);
let page = match parent {
Some((pi, qi)) => d.parents.get(pi).and_then(|sp| sp.pages.get(qi)),
None => d.page(cur),
};
if let Some(p) = page.cloned() {
let linked = temp_bool(ui, "margins_linked", true);
let b = block(ui, 2.0 * FIELD_H + 5.0);
let mut set: Option<designcraft_doc::Margins> = None;
@@ -289,7 +295,11 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) {
set_temp_bool(ui, "margins_linked", !linked);
}
if let Some(m) = set {
let _ = app.run("layout.marginsAndColumns", json!({"margins": m}));
let target = match parent {
Some((pi, _)) => json!({"margins": m, "parent": pi}),
None => json!({"margins": m}),
};
let _ = app.run("layout.marginsAndColumns", target);
}
}
ui.add_space(6.0);
+1 -1
View File
@@ -369,7 +369,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections.
- Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas.
- Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides (a page's own and its parent page's), margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture.
- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread.
- Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet.
- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited.