From 9595f3d62e62963df4e3118b24c3b0df8a523601 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:23:50 -0800 Subject: [PATCH 1/6] Anchored text frames: test nested hosts in groups, footnotes and table cells (#2722) Adds three tests for a text frame anchored inside another anchored text frame: the outer frame as a member of an anchored group with a rotation and a scale, the inner frame in a footnote of the outer frame, and the inner frame in a table cell of the outer frame. Each checks the inner frame's spread, transform and page. --- crates/compose/src/tests.rs | 120 ++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 59777d657..3c182f871 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -9622,6 +9622,126 @@ fn a_frame_anchored_in_an_anchored_frame_is_placed_through_both_hosts() { assert_eq!(Cache::new().get(&d, isid, None).frames.len(), 1); } +/// An unplaced `w` × `h` text frame showing a new story of `text`, ready to anchor. +fn anchored_text_frame(d: &mut Document, text: &str, w: f64, h: f64) -> (designcraft_doc::Item, StoryId) { + let lid = d.default_layer(); + let (fid, fsid) = (ItemId(d.alloc()), StoryId(d.alloc())); + let mut story = Story::with_text(fsid, text, ParaFormat::default()); + story.frames.push(fid); + d.stories.insert(fsid, Arc::new(story)); + let mut it = + designcraft_doc::Item::new(fid, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, w, h))); + it.content = Content::Text(designcraft_doc::TextFrame { story: fsid, options: Default::default() }); + (it, fsid) +} + +/// A one-page document whose body frame holds `outer` anchored before its second line. Returns +/// the body frame and the body story's composition. +fn body_holding(d: &mut Document, outer: designcraft_doc::Item) -> (ItemId, ComposedStory) { + let lid = d.default_layer(); + let body = "One.\nTwo.\nThree."; + let (bf, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 400.0, 400.0), lid, body, ParaFormat::default()).unwrap(); + d.story_mut(sid).unwrap().insert_object(body.find("Two").unwrap(), designcraft_doc::AnchoredObject::new(outer, Default::default())); + (bf, compose_story(d, sid, &ComposeOptions::default())) +} + +/// `isid`'s one frame, `inner`, is placed on page 1 with parent space `want`, and the cache +/// composes it there. +fn assert_placed_on_page_one(d: &Document, isid: StoryId, inner: ItemId, want: Affine) { + let placed = placed::placements(d, isid, &mut |h| Arc::new(compose_story(d, h, &ComposeOptions::default()))); + assert_eq!(placed.len(), 1, "{placed:?}"); + let (id, p) = placed[0]; + assert_eq!((id, p.spread), (inner, SpreadRef::Doc(0))); + let (got, want) = (p.xf.as_coeffs(), want.as_coeffs()); + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{got:?} != {want:?}"); + assert_eq!(frame_specs_placed(d, isid, &placed)[0].page, Some(0)); + assert_eq!(Cache::new().get(d, isid, None).frames.len(), 1); +} + +/// A text frame anchored in an anchored text frame that is a member of an anchored group with a +/// rotation and a scale is placed through the group's transform and the member's own. +#[test] +fn a_frame_anchored_in_a_grouped_anchored_frame_is_placed_through_the_group() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let (mut outer, osid) = anchored_text_frame(&mut d, "Outer text", 200.0, 100.0); + let (inner, isid) = anchored_text_frame(&mut d, "Inner", 80.0, 30.0); + let (outer_id, inner_id) = (outer.id, inner.id); + outer.xf = Affine::translate((12.0, 7.0)); + d.story_mut(osid).unwrap().insert_object("Outer ".len(), designcraft_doc::AnchoredObject::new(inner, Default::default())); + let mut g = + designcraft_doc::Item::new(ItemId(d.alloc()), d.default_layer(), designcraft_doc::Shape::Group, designcraft_geom::PathData::default()); + g.content = Content::Group { items: vec![Arc::new(outer)] }; + g.xf = Affine::rotate(0.25) * Affine::scale(1.25); + let (gid, gxf) = (g.id, g.xf); + let (bf, host) = body_holding(&mut d, g); + + let o1 = &host.frame(bf).unwrap().objects[0]; + // The composed group keeps its rotation and scale; its offset puts its box at the object's origin. + let gxf_placed = o1.object.item.xf; + assert_eq!((o1.object.item.id, gxf_placed.as_coeffs()[..4].to_vec()), (gid, gxf.as_coeffs()[..4].to_vec())); + let mid = compose_story(&d, osid, &ComposeOptions::default()); + let o2 = &mid.frame(outer_id).unwrap().objects[0]; + let want = d.text_xf(d.item(bf).unwrap()) + * Affine::translate(o1.origin.to_vec2()) + * gxf_placed + * d.text_xf(d.anchored_item(outer_id).unwrap()) + * Affine::translate(o2.origin.to_vec2()); + assert_placed_on_page_one(&d, isid, inner_id, want); +} + +/// A text frame anchored in a footnote of an anchored text frame is placed through the footnote's +/// spot in that frame as well as both hosts. +#[test] +fn a_frame_anchored_in_a_footnote_of_an_anchored_frame_is_placed_through_the_footnote() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let (outer, osid) = anchored_text_frame(&mut d, "Outer text", 200.0, 120.0); + let (inner, isid) = anchored_text_frame(&mut d, "Inner", 80.0, 30.0); + let (outer_id, inner_id) = (outer.id, inner.id); + let st = d.story_mut(osid).unwrap(); + st.insert_note("Outer".len(), "Note text", ParaFormat::default()); + Arc::make_mut(&mut st.notes[0]).text.insert_object("Note ".len(), designcraft_doc::AnchoredObject::new(inner, Default::default())); + let (bf, host) = body_holding(&mut d, outer); + + let o1 = &host.frame(bf).unwrap().objects[0]; + let mid = compose_story(&d, osid, &ComposeOptions::default()); + let note = &mid.frame(outer_id).unwrap().notes[0]; + let o2 = ¬e.text.frames[0].objects[0]; + let want = d.text_xf(d.item(bf).unwrap()) + * Affine::translate(o1.origin.to_vec2()) + * d.text_xf(d.anchored_item(outer_id).unwrap()) + * Affine::translate(note.origin.to_vec2()) + * Affine::translate(o2.origin.to_vec2()); + assert_placed_on_page_one(&d, isid, inner_id, want); +} + +/// A text frame anchored in a table cell of an anchored text frame is placed through the cell's +/// spot in that frame as well as both hosts. +#[test] +fn a_frame_anchored_in_a_table_cell_of_an_anchored_frame_is_placed_through_the_cell() { + let mut d = Document::new(&NewDocument { pages: 1, ..Default::default() }); + let (outer, osid) = anchored_text_frame(&mut d, "Outer text", 200.0, 150.0); + let (inner, isid) = anchored_text_frame(&mut d, "Inner", 80.0, 30.0); + let (outer_id, inner_id) = (outer.id, inner.id); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + table.cells[0].text.insert(0, "Cell "); + table.cells[0].text.insert_object("Cell ".len(), designcraft_doc::AnchoredObject::new(inner, Default::default())); + let st = d.story_mut(osid).unwrap(); + let end = st.len(); + st.insert_table(end, table); + let (bf, host) = body_holding(&mut d, outer); + + let o1 = &host.frame(bf).unwrap().objects[0]; + let mid = compose_story(&d, osid, &ComposeOptions::default()); + let cell = &mid.frame(outer_id).unwrap().tables[0].cells[0]; + let o2 = &cell.text.frames[0].objects[0]; + let want = d.text_xf(d.item(bf).unwrap()) + * Affine::translate(o1.origin.to_vec2()) + * d.text_xf(d.anchored_item(outer_id).unwrap()) + * Affine::translate(cell.origin.to_vec2()) + * Affine::translate(o2.origin.to_vec2()); + assert_placed_on_page_one(&d, isid, inner_id, want); +} + /// A marginal note (an anchored text frame, Only Align First Line to Grid, 9/10) hangs 15 pt /// above its anchor's baseline on a right-hand page. It composes against the document grid where /// it is placed: its first line moves down onto the grid line, the second follows 10 pt below, From c803ea80b10429204dba64baede4be5c2846a0aa Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:24:34 -0800 Subject: [PATCH 2/6] Compose: vertical justification in a shaped frame tests lines to their baselines (#2708) Center, Bottom and Justify tested every moved line except the last against the frame's outline down to its descent, so an outline that narrows under an earlier line held the text higher by that line's descent. Each moved line is now tested from its top down to its baseline, and its descenders may cross the outline, as the last line's already did. Above footnotes and in vertical frames the descenders still stay inside. --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 12 +++++++----- crates/compose/src/tests.rs | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index afecef5d3..9e83d73ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -395,6 +395,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log - **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change. +- **2026-11-12** · Center, Bottom and Justify vertical justification in a shaped frame test each moved line against the outline down to its baseline. The descenders of earlier lines may cross the outline, as the last line's do, so an outline that narrows under an earlier line no longer holds the text higher by that line's descent. No readiness percentage change. - **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change. - **2026-11-12** · A placed JPEG drawn at a half, a quarter or an eighth of its pixel size or less is decoded at that size straight from its compressed blocks, so the canvas, page thumbnails and loaded-graphic thumbnails no longer decode and keep the full-size picture for it. CMYK JPEGs and the full size still decode in full. No readiness percentage change. - **2026-11-12** · A decoded placed image becomes a screen pixmap in its own buffer, premultiplied in place, and opaque pixels are left as they are: 120 ms becomes 7 ms for a 24-megapixel opaque photo, with the same pixels. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9c423cec0..e2665720e 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -6066,12 +6066,14 @@ fn vertical_justify( }; // Only the last row's descenders hang below the bottom of the text: an earlier line whose // ink reaches below the last baseline (tight leading) isn't moved past the text area. - // In a shaped frame each moved line's slot stays inside the outline over its new band, as - // when the line was fitted: a frame that narrows towards the bottom holds the text higher. - let in_outline = |l: &Line, last: bool, dy: f64| match &f.outline { + // In a shaped frame each moved line's slot stays inside the outline from its top down to + // its baseline: a frame that narrows towards the bottom holds the text higher. Every moved + // line's descenders may cross the outline, as the last line's do below the bottom of the + // text; above footnotes, and in vertical frames, they stay inside. + let in_outline = |l: &Line, dy: f64| match &f.outline { Some(o) => { let b = l.baseline + dy; - let desc = if last && !inside { 0.0 } else { l.descent.max(0.0) }; + let desc = if inside { l.descent.max(0.0) } else { 0.0 }; let tol = 1e-6; o.intervals(l.x0, l.x1, (b - l.ascent).max(f.area.y0).min(b), b + desc).iter().any(|&(a, z)| a <= l.x0 + tol && z >= l.x1 - tol) } @@ -6095,7 +6097,7 @@ fn vertical_justify( let fits = |shift: &[f64]| { idx.iter().zip(&rows).rev().all(|(&i, &k)| { let l = &ft.lines[i]; - shift[k] <= 0.0 || ((k == n || l.baseline + l.descent + shift[k] <= limit + 1e-9) && in_outline(l, k == n, shift[k])) + shift[k] <= 0.0 || ((k == n || l.baseline + l.descent + shift[k] <= limit + 1e-9) && in_outline(l, shift[k])) }) && clear(shift) }; // In a rectangular frame fit decreases monotonically as the shift grows. A shaped diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3c182f871..38dad232c 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3019,13 +3019,13 @@ fn vertical_justification_keeps_lines_inside_a_shaped_frame() { assert!(lines.len() >= 2, "{mode:?}: {lines:?}"); (spec.area, lines) }; - // Narrowing downward: |x - 150| <= (300 - y) / 2 over each slot's band, the last line's - // descenders left out. + // Narrowing downward: |x - 150| <= (300 - y) / 2 over each slot's band, from the line's top to + // its baseline. let down = [Point::new(0.0, 0.0), Point::new(300.0, 0.0), Point::new(150.0, 300.0)]; for mode in [Center, Bottom, Justify] { let (area, lines) = compose_with(down, mode); for (k, l) in lines.iter().enumerate() { - let bottom = if k + 1 == lines.len() { l.baseline } else { l.baseline + l.descent }; + let bottom = l.baseline; let top = (l.baseline - l.ascent).max(area.y0); for (x, y) in [(l.x0, top), (l.x1, top), (l.x0, bottom), (l.x1, bottom)] { assert!((x - 150.0).abs() <= (300.0 - y) / 2.0 + 1e-3, "{mode:?}: line {k} corner ({x}, {y}) outside the triangle: {lines:?}"); @@ -3055,6 +3055,33 @@ fn vertical_justification_keeps_lines_inside_a_shaped_frame() { assert!((last.baseline - 260.0).abs() < 1e-3, "Bottom: last baseline at {}", last.baseline); } +/// In a shaped frame, Center and Bottom move every line until its slot reaches the outline at its +/// baseline: an earlier line's descenders may cross the outline, as the last line's do. A frame +/// full width down to 20 pt and half width below it holds the first line's baseline at 20 pt. +#[test] +fn vertical_justification_lets_earlier_descenders_cross_a_shaped_frame() { + use designcraft_doc::VerticalJustification::{Bottom, Center}; + use designcraft_geom::{Anchor, PathData, Point, SubPath}; + let pts = [(0.0, 0.0), (300.0, 0.0), (300.0, 20.0), (150.0, 20.0), (150.0, 300.0), (0.0, 300.0)]; + for mode in [Center, Bottom] { + let (mut d, sid, fid) = doc_with("Typography\ngypsy", Rect::new(0.0, 0.0, 300.0, 300.0), ParaAttrs::default()); + let it = d.item_mut(fid).unwrap(); + it.path = PathData::single(SubPath { anchors: pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect(), closed: true }); + let o = &mut it.text_frame_mut().unwrap().options; + o.vertical_justification = mode; + o.inset = [0.0; 4]; + assert!(frame_specs(&d, sid)[0].outline.is_some()); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert_eq!(lines.len(), 2, "{mode:?}: {lines:?}"); + let (first, last) = (lines[0], lines[1]); + assert!((first.x1 - 300.0).abs() < 1e-6 && (last.x1 - 150.0).abs() < 1e-6, "{mode:?}: slots {lines:?}"); + assert!(first.descent > 1.0, "{mode:?}: {first:?}"); + assert!((first.baseline - 20.0).abs() < 1e-3, "{mode:?}: first baseline at {}, step at 20", first.baseline); + assert!(last.baseline <= 300.0 + 1e-6, "{mode:?}: last baseline at {}", last.baseline); + } +} + /// In a shaped frame with a text wrap under the text, Center, Bottom and Justify keep the lines /// inside the outline and out of the wrap together: in a triangle that widens downward, Bottom and /// Justify put the last baseline on the wrap's top and Center moves half as far; in one that From 6923c220ad3920415efe0e12bd3c59e9295e7b95 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:24:40 -0800 Subject: [PATCH 3/6] Text wrap: Bottom and Center move a line down between a contour's arms (#2719) A line set in the open middle of a U-shaped contour now goes down to the contour's base. The clearance check tests the occupied parts of the contour in the line's band, as line setting does, instead of its outer extent across both arms. --- crates/compose/src/lib.rs | 7 ++++--- crates/compose/src/tests.rs | 38 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index e2665720e..a367e7ade 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -6309,9 +6309,10 @@ fn wrap_rooms(ft: &FrameText, f: &FrameSpec, c: u32, spanned: bool, anchors: &[u { // A contour beside or under this line whose box reaches the line's slot (the // contour stays inside its box). Moved down by `d`, the line's band sweeps from its - // top to its baseline plus `d`; the contour's extent there only grows with `d`, so - // the furthest clear move is found by bisection to 0.01 pt. - let hits = |d: f64| contour.band_extent(top + dy, bottom + d + dy).and_then(|x| sided(e, col, x)).is_some_and(meets); + // top to its baseline plus `d`; the parts of the contour there only grow with `d`, + // so the furthest clear move is found by bisection to 0.01 pt. As when the line is + // set, the gaps between parts of the contour stay open. + let hits = |d: f64| contour.band_spans(top + dy, bottom + d + dy).into_iter().any(|x| sided(e, col, x).is_some_and(meets)); let (mut lo, mut hi) = ((r.y0 - bottom).max(0.0), (r.y1 - top).min(f.area.y1 - bottom).min(cap)); if lo < hi && hits(hi) { if hits(lo) { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 38dad232c..ccc71578d 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -723,6 +723,44 @@ fn vertical_justification_stops_where_a_contour_under_the_text_meets_it() { assert!(b > 122.0, "Bottom stopped the line at {b}, short of where the contour meets the column at 122.5"); } +/// A line set in the open middle of a U-shaped contour goes down between its arms: Bottom stops +/// it on the contour's base, not where the line starts because the arms span its slot. +#[test] +fn vertical_justification_moves_a_line_down_between_the_arms_of_a_contour() { + use designcraft_doc::VerticalJustification::{Bottom, Top}; + // Arms 0..15 and 45..60 from y = 0, joined by a base from y = 170 down to y = 190. + let pts = [ + Point::new(0.0, 0.0), + Point::new(15.0, 0.0), + Point::new(15.0, 170.0), + Point::new(45.0, 170.0), + Point::new(45.0, 0.0), + Point::new(60.0, 0.0), + Point::new(60.0, 190.0), + Point::new(0.0, 190.0), + ]; + let run = |vj| { + let (mut d, sid, fid) = doc_with("42", Rect::new(0.0, 0.0, 60.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = vj; + let lid = d.default_layer(); + let path = designcraft_geom::path::PathData::single(designcraft_geom::path::SubPath::polyline(&pts, true)); + let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, path); + it.wrap.mode = WrapMode::Contour; + it.wrap.side = WrapSide::BothSides; + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let l = all_lines(&cs)[0]; + (l.baseline, l.x0, l.x1) + }; + let (top, x0, x1) = run(Top); + assert!(x0 >= 15.0 - 1e-6 && x1 <= 45.0 + 1e-6, "Top sets the line between the arms: {x0}..{x1}"); + let (b, x0, x1) = run(Bottom); + assert!(x0 >= 15.0 - 1e-6 && x1 <= 45.0 + 1e-6, "Bottom keeps the line between the arms: {x0}..{x1}"); + assert!(b <= 170.0 + 1e-6, "Bottom moved the baseline to {b}, into the contour's base at 170"); + assert!(b > 169.9, "Bottom stopped the line at {b} (Top at {top}), short of the contour's base at 170"); +} + /// An object anchored in the text and positioned from its anchor moves down with the lines, so /// Center places lines above it as it places them without its wrap. #[test] From 66b8c8ba3e1b66faf8b48b69647e2e982761a437 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:25:39 -0800 Subject: [PATCH 4/6] Object styles: checks and fixes for corners, strokes and the style dialog (#2721) - Corner options use path order for a path with more than one subpath, so a second rectangle no longer takes the first one's corner order. - IDML export writes EnableStrokeAndCornerOptions="false" for a style without corners, so it does not take corners from its based-on style on import. - style.object.create and style.object.edit refuse a stroke gap tint outside 0 to 1 and a stroke type field that its kind does not take, as object.stroke does. An edit that changes nothing returns restyled: 0. - New Object Style and Object Style Options read a bare number in the document's ruler unit, keep a Text Frame General measurement that was not edited at its exact value, and New Object Style refuses a name another style has. - Clear Overrides in the Object Styles panel is enabled when any selected object differs from its style. --- crates/engine/src/cmd/object.rs | 30 +++--- crates/engine/src/cmd/style.rs | 4 +- crates/engine/src/cmd/style/object.rs | 17 +++- crates/engine/src/tests.rs | 19 +++- crates/geom/src/corners.rs | 10 +- crates/idml/src/export/styles.rs | 2 +- crates/idml/src/tests/object_styles.rs | 14 +++ crates/ui-egui/src/object_style_dialog.rs | 114 +++++++++++++++++++--- crates/ui-egui/src/panels/styles.rs | 58 +++++++++-- 9 files changed, 222 insertions(+), 46 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 03e93a35b..c269e4f1d 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -26,6 +26,22 @@ pub(crate) fn known_swatch(doc: &designcraft_doc::Document, name: &str, cmd: &st Err(bad(cmd, format!("{key}: no swatch named `{name}`{hint}; create one with `swatch.create` and use its returned name"))) } +/// The fields of a stroke `type` object must be `kind` and the one its kind takes (`pattern` for +/// dashed, `bands` for stripes, `name` for style). `kind` is the type's own, else `base_kind`. +pub(crate) fn check_stroke_type_fields(v: &Value, base_kind: Option<&str>, cmd: &str) -> Result<()> { + let Some(fields) = v.as_object() else { return Ok(()) }; + let field = match v.get("kind").and_then(Value::as_str).or(base_kind) { + Some("dashed") => Some("pattern"), + Some("stripes") => Some("bands"), + Some("style") => Some("name"), + _ => None, + }; + match fields.keys().find(|key| *key != "kind" && Some(key.as_str()) != field) { + Some(key) => Err(bad(cmd, format!("type: unknown field `{key}`"))), + None => Ok(()), + } +} + pub fn specs() -> Vec { vec![ cmd!( @@ -265,19 +281,7 @@ pub fn specs() -> Vec { let kind: Option = p .get("type") .map(|v| { - if let Some(fields) = v.as_object() { - let field = match v.get("kind").and_then(Value::as_str) { - Some("dashed") => Some("pattern"), - Some("stripes") => Some("bands"), - Some("style") => Some("name"), - _ => None, - }; - for key in fields.keys() { - if key != "kind" && Some(key.as_str()) != field { - return Err(bad("object.stroke", format!("type: unknown field `{key}`"))); - } - } - } + check_stroke_type_fields(v, None, "object.stroke")?; serde_json::from_value(v.clone()).map_err(|e| bad("object.stroke", format!("type: {e}"))) }) .transpose()?; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 7e55e0529..0b592786c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -182,7 +182,7 @@ pub fn specs() -> Vec { "New Object Style…", [], None, - "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …} (swatches must exist; unknown fill and stroke keys are refused), corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)", + "{name, fromSelection?: true (fill, stroke, corners, effects and text frame options of the first selected item), fill?: swatch | {swatch?, tint?}, stroke?: {swatch?, weight?, tint?, type?, …} (swatches must exist; tint and gapTint 0–1; unknown fill, stroke and stroke type keys are refused), corners?: {shape?, size?} | {corners: [{shape, size}] ×4}, paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?}, dropShadow?: {on?, color?, opacity? (0–1), angle?, globalLight?, distance?, size?, spread?}, feather?: width, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} → {name} (made unique)", has_doc, create_object ), @@ -191,7 +191,7 @@ pub fn specs() -> Vec { "Object Style Options…", [], None, - "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null (swatches must exist; unknown fill and stroke keys are refused), corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them; the text of frames still in the old paragraph styles takes the new ones → {name, updated, restyled}", + "{name, rename?: new name (every use follows), fill?: swatch | {swatch?, tint?} | null, stroke?: {swatch?, weight?, tint?, type?, …} | null (swatches must exist; tint and gapTint 0–1; unknown fill, stroke and stroke type keys are refused), corners?: {shape?: none|rounded|inverseRounded|inset|bevel|fancy, size?} | {corners: [{shape, size}] ×4} | null (null: the style no longer sets it; items keep theirs), paragraphStyle?: a paragraph style (null: none; the text of a frame the style is applied to takes it), applyNextStyle?: bool (each paragraph after the first takes the Next Style of the one before), textFrame?: {columns? (1–40), columnsKind?, gutter?, columnWidth?, balanceColumns?, inset?: [top, left, bottom, right] | one number for all four, verticalJustification?: top|center|bottom|justify, vjParagraphSpacingLimit?, ignoreWrap?} | null (Text Frame General Options, named as `document.inspect` names text frame options; lengths 0–15552 pt; other keys are refused; a frame keeps its baseline, auto-size and column rule settings; null: the style sets none), effects?: {…} | null (the style no longer sets effects), dropShadow?, feather?, innerShadow?, outerGlow?, innerGlow?, bevel?, satin?, gradientFeather?, directionalFeather?} — effect objects merge field by field into the style's effects (fields as in `document.inspect` item effects); values that aren't finite numbers are refused, the others clamped (opacities 0–1, sizes and distances ≥ 0); items using the style whose effects (fill, stroke, corners) match the style's (an item's defaults, when the style set none) take the new ones, items with their own keep them; the text of frames still in the old paragraph styles takes the new ones → {name, updated, restyled}", has_doc, edit_object ), diff --git a/crates/engine/src/cmd/style/object.rs b/crates/engine/src/cmd/style/object.rs index a8572a3fa..630eaf117 100644 --- a/crates/engine/src/cmd/style/object.rs +++ b/crates/engine/src/cmd/style/object.rs @@ -243,11 +243,17 @@ fn bool_param(cmd: &str, p: &Value, key: &str) -> Result> { } } -/// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0 -/// or the tint isn't 0–1. +/// `p.stroke` merged over `base` (as [`group_param`]), refused when the weight isn't a number ≥ 0, +/// the tint or gap tint isn't 0–1, or the type has a field its kind doesn't take. fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> Result>> { let optional = ["gapOverprint", "gradientAngle", "gradientVector"]; - let st = group_param(cmd, p, "stroke", base.unwrap_or(&designcraft_doc::Stroke::default()), &optional)?; + let base = base.cloned().unwrap_or_default(); + // A type without `kind` keeps the style's kind, so it takes that kind's fields. + if let Some(t) = p.get("stroke").and_then(|s| s.get("type")) { + let base_kind = serde_json::to_value(&base.kind).ok().and_then(|k| k.get("kind").and_then(Value::as_str).map(str::to_string)); + crate::cmd::object::check_stroke_type_fields(t, base_kind.as_deref(), cmd)?; + } + let st = group_param(cmd, p, "stroke", &base, &optional)?; if let Some(Some(st)) = &st { if !(st.weight.is_finite() && st.weight >= 0.0) { return Err(bad(cmd, "stroke weight must be a number ≥ 0")); @@ -255,6 +261,9 @@ fn stroke_param(cmd: &str, p: &Value, base: Option<&designcraft_doc::Stroke>) -> if !(0.0..=1.0).contains(&st.tint) { return Err(bad(cmd, "stroke tint must be 0..1")); } + if !(0.0..=1.0).contains(&st.gap_tint) { + return Err(bad(cmd, "stroke gap tint must be 0..1")); + } } Ok(st) } @@ -382,7 +391,7 @@ pub(super) fn edit_object(s: &mut Session, p: &Value) -> Result { new.name = r.clone(); } if new == old { - return Ok(json!({"name": name, "updated": 0})); + return Ok(json!({"name": name, "updated": 0, "restyled": 0})); } s.edit(|d, _| { let Some(os) = d.styles_mut().object.iter_mut().find(|o| o.name == name) else { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index afbd25e73..10b42ee68 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -752,8 +752,9 @@ fn object_style_effects_follow_the_style_until_overridden() { assert_eq!(s.doc().unwrap().revision, rev); } -/// `style.object.create` and `edit` refuse an unknown swatch, a misspelled setting, a word or null -/// for a switch, a rename that isn't a name and a corner size outside 0–10000 pt. +/// `style.object.create` and `edit` refuse an unknown swatch, a misspelled setting (a stroke type's +/// included), a word or null for a switch, a rename that isn't a name, a corner size outside +/// 0–10000 pt and a stroke gap tint outside 0–1. #[test] fn object_style_parameters_are_checked() { let mut s = session(); @@ -768,6 +769,11 @@ fn object_style_parameters_are_checked() { json!({"corners": {"size": -1}}), json!({"corners": {"size": 10_001}}), json!({"applyNextStyle": null}), + json!({"stroke": {"gapTint": -1}}), + json!({"stroke": {"gapTint": 1.5}}), + json!({"stroke": {"type": {"kind": "dashed", "pattern": [2, 2], "patern": [4, 4]}}}), + json!({"stroke": {"type": {"kind": "solid", "pattern": [2, 2]}}}), + json!({"stroke": {"type": {"bands": [[0, 0.5]]}}}), ] { let mut p = bad.clone(); p["name"] = json!("Framed"); @@ -787,6 +793,15 @@ fn object_style_parameters_are_checked() { assert!(!names.iter().any(|n| n == "Other" || n == "Odd"), "{names:?}"); } +/// An edit that leaves the style as it is answers with the same fields as one that changes it. +#[test] +fn an_object_style_edit_that_changes_nothing_reports_no_restyled_frames() { + let mut s = session(); + s.execute("style.object.create", &json!({"name": "Framed", "fromSelection": false})).unwrap(); + let r = s.execute("style.object.edit", &json!({"name": "Framed"})).unwrap(); + assert_eq!((r["name"].as_str(), r["updated"].as_u64(), r["restyled"].as_u64()), (Some("Framed"), Some(0), Some(0)), "{r}"); +} + /// The course's "Rounded Corner Image, White Stroke" style: made from a frame with rounded corners /// and a paper stroke, applied to another frame, then edited (a thicker stroke, bigger corners): /// the frames that follow the style change, one with its own stroke keeps it; a rename follows. diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 9c1bfbb00..10db62fd4 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -60,7 +60,7 @@ pub struct CornerOptions { /// anchors at four different corners keeps a rectangle's order. pub fn named_slots(path: &PathData) -> [usize; 4] { let rect_order = [0, 1, 2, 3]; - let Some(sp) = path.subpaths.first() else { return rect_order }; + let [sp] = path.subpaths.as_slice() else { return rect_order }; let Some(b) = path.bounds() else { return rect_order }; if sp.anchors.len() != 4 { return rect_order; @@ -317,6 +317,14 @@ mod tests { assert_eq!(named.named_to_path(&rectangle(Rect::new(0.0, 0.0, 10.0, 10.0))), named); let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(5.0, 9.0)], true)); assert_eq!(named.named_to_path(&tri), named); + // A path of two rectangles keeps the order: one subpath's order can't name the other's + // anchors. + let two = PathData::new(vec![ + SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)], true), + SubPath::polyline(&[Point::new(40.0, 40.0), Point::new(60.0, 40.0), Point::new(60.0, 60.0), Point::new(40.0, 60.0)], true), + ]); + assert_eq!(named_slots(&two), [0, 1, 2, 3]); + assert_eq!(named.named_to_path(&two), named); } #[test] diff --git a/crates/idml/src/export/styles.rs b/crates/idml/src/export/styles.rs index d131dc19d..b5dc46cff 100644 --- a/crates/idml/src/export/styles.rs +++ b/crates/idml/src/export/styles.rs @@ -121,8 +121,8 @@ impl<'a> Ex<'a> { el.set("EnableStroke", bool_s(os.stroke.is_some())); el.set("EnableParagraphStyle", bool_s(os.paragraph_style.is_some())); el.set("EnableTextFrameGeneralOptions", bool_s(os.text_frame.is_some())); + el.set("EnableStrokeAndCornerOptions", bool_s(os.corners.is_some())); if let Some(c) = &os.corners { - el.set("EnableStrokeAndCornerOptions", "true"); for (n, c) in ["TopLeft", "TopRight", "BottomRight", "BottomLeft"].iter().zip(c.corners) { el.set(&format!("{n}CornerOption"), names::corner_out(c.shape)); el.set(&format!("{n}CornerRadius"), num(c.size)); diff --git a/crates/idml/src/tests/object_styles.rs b/crates/idml/src/tests/object_styles.rs index 52cfd3e20..719936f40 100644 --- a/crates/idml/src/tests/object_styles.rs +++ b/crates/idml/src/tests/object_styles.rs @@ -177,6 +177,20 @@ fn object_style_apply_next_style_round_trips() { } } +/// A style that sets no corners keeps none after export and import, though the style it is based +/// on sets rounded corners. +#[test] +fn a_style_without_corners_keeps_none_over_its_based_on_style() { + let mut d = small_doc(); + let rounded = Some(designcraft_geom::corners::CornerOptions::uniform(CornerShape::Rounded, 12.0)); + let styles = std::sync::Arc::make_mut(&mut d.styles); + styles.object.push(designcraft_doc::ObjectStyle { name: "Round".into(), corners: rounded, ..Default::default() }); + styles.object.push(designcraft_doc::ObjectStyle { name: "Square".into(), based_on: Some("Round".into()), ..Default::default() }); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(back.styles.object_style("Round").and_then(|s| s.corners), rounded); + assert_eq!(back.styles.object_style("Square").and_then(|s| s.corners), None); +} + /// A style's Text Frame General Options take each setting its own `TextFramePreference` leaves out /// from the style it is based on (named by its id or bare name, and only when that style doesn't /// turn the category off), and export and import keep the merged options. diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 4200732a9..a89ef46d9 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -28,20 +28,31 @@ const ATTRIBUTES: [(&str, &str); 6] = [ /// The fields of the Paragraph Styles and Text Frame General Options pages: the style's paragraph /// style (`""`: none), Apply Next Style, and text frame options `tf` as the Text Frame Options -/// dialog shows them (`tfBase` keeps the options the page doesn't show). +/// dialog shows them (`tfBase` keeps the options as they are, `tfShown` the fields as first shown). fn text_fields(app: &DesignApp, fields: &mut Value, paragraph: Option<&str>, next: bool, tf: &designcraft_doc::TextFrameOptions) { - let units = app.session.active().map_or(designcraft_geom::Unit::Picas, |s| s.doc.settings.horizontal_units); fields["paragraphStyle"] = json!(paragraph.unwrap_or("")); fields["applyNext"] = json!(next); fields["tfBase"] = serde_json::to_value(tf).unwrap_or_default(); fields["insetLinked"] = json!(tf.inset.iter().all(|v| (v - tf.inset[0]).abs() < 1e-9)); - if let (Value::Object(f), Value::Object(shown)) = - (fields, crate::dialogs::text_frame_fields(tf, units, None, &crate::dialogs::document_grid(app))) - { + let shown = crate::dialogs::text_frame_fields(tf, doc_units(app), None, &crate::dialogs::document_grid(app)); + fields["tfShown"] = shown.clone(); + if let (Value::Object(f), Value::Object(shown)) = (fields, shown) { f.extend(shown); } } +/// The active document's ruler unit, which a bare number in a measurement field is in. +fn doc_units(app: &DesignApp) -> designcraft_geom::Unit { + app.session.active().map_or(designcraft_geom::Unit::Picas, |s| s.doc.settings.horizontal_units) +} + +/// The dialog with `fields`, reading bare numbers in the document's ruler unit. +fn dialog(app: &DesignApp, fields: Value) -> Dialog { + let mut d = Dialog::new(ID, fields); + d.unit = doc_units(app); + d +} + /// The active document as the dialog records it: its uid and the epoch that File › Revert /// changes. fn document_key(app: &DesignApp) -> Value { @@ -85,7 +96,7 @@ pub fn open_new(app: &mut DesignApp) { "apply": from, }); text_fields(app, &mut fields, None, false, &frame.unwrap_or_default()); - app.ui.dialog = Some(Dialog::new(ID, fields)); + app.ui.dialog = Some(dialog(app, fields)); } /// Object Style Options for style `name`; refused for a style the document doesn't have and for @@ -116,7 +127,7 @@ pub fn open_edit(app: &mut DesignApp, name: &str) -> Result<(), String> { "apply": false, }); text_fields(app, &mut fields, os.paragraph_style.as_deref(), os.apply_next_style, &os.text_frame.clone().unwrap_or_default()); - app.ui.dialog = Some(Dialog::new(ID, fields)); + app.ui.dialog = Some(dialog(app, fields)); Ok(()) } @@ -157,23 +168,31 @@ fn frame_options(d: &Dialog) -> Result } let mut o: designcraft_doc::TextFrameOptions = d.fields.get("tfBase").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); let bad = |what: &str| format!("Text Frame General Options: {what}"); - let measure = |key: &str| d.m(key).filter(|v| v.is_finite() && (0.0..=15552.0).contains(v)); + // A field still as first shown keeps the option's value, which the field shows rounded. + let shown = d.fields.get("tfShown").cloned().unwrap_or(Value::Null); + let measure = |key: &str, was: f64| { + if d.fields.get(key).is_some_and(|v| *v == shown[key]) { + return Some(was); + } + d.m(key).filter(|v| v.is_finite() && (0.0..=15552.0).contains(v)) + }; o.columns = d .n("columns") .filter(|n| (1.0..=40.0).contains(n) && n.fract() == 0.0) .ok_or_else(|| bad("the number of columns is a whole number from 1 to 40"))? as u32; - o.gutter = measure("gutter").ok_or_else(|| bad("the gutter is not a measure"))?; + o.gutter = measure("gutter", o.gutter).ok_or_else(|| bad("the gutter is not a measure"))?; o.columns_kind = serde_json::from_value(json!(d.s("columnsKind"))).unwrap_or(o.columns_kind); if o.columns_kind == designcraft_doc::ColumnsKind::FixedWidth { - o.column_width = measure("columnWidth").ok_or_else(|| bad("the column width is not a measure"))?; + o.column_width = measure("columnWidth", o.column_width).ok_or_else(|| bad("the column width is not a measure"))?; } o.balance_columns = d.b("balanceColumns"); for (side, key) in o.inset.iter_mut().zip(["insetTop", "insetLeft", "insetBottom", "insetRight"]) { - *side = measure(key).ok_or_else(|| bad("an inset is not a measure"))?; + *side = measure(key, *side).ok_or_else(|| bad("an inset is not a measure"))?; } o.vertical_justification = serde_json::from_value(json!(d.s("verticalJustification"))).unwrap_or(o.vertical_justification); if o.vertical_justification == designcraft_doc::VerticalJustification::Justify { - o.vj_paragraph_spacing_limit = measure("paragraphSpacingLimit").ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?; + o.vj_paragraph_spacing_limit = + measure("paragraphSpacingLimit", o.vj_paragraph_spacing_limit).ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?; } o.ignore_wrap = d.b("ignoreWrap"); Ok(Some(o)) @@ -198,6 +217,10 @@ pub fn confirm(app: &mut DesignApp, d: &Dialog) -> Result { let mut p = attrs(d); p.insert("textFrame".into(), frame_options(d)?.map_or(Value::Null, |o| general_json(&o))); if d.b("new") { + // A name another style has is refused, as Object Style Options refuses it. + if app.session.active().is_some_and(|st| st.doc.styles.object_style(&name).is_some()) { + return Err(format!("an object style is already named `{name}`")); + } p.insert("name".into(), json!(name)); p.insert("fromSelection".into(), json!(false)); let r = app.run("style.object.create", Value::Object(p)).map_err(|e| e.to_string())?; @@ -775,8 +798,8 @@ mod tests { assert!(app.ui.dialog.as_ref().is_some_and(|d| d.b("new"))); } - /// A refused OK (an empty name, a name another style has) keeps the dialog and its edits, - /// with the reason shown. + /// A refused OK (an empty name, a name another style has, in Object Style Options and in New + /// Object Style) keeps the dialog and its edits, with the reason shown. #[test] fn a_refused_ok_keeps_the_dialog_open_with_the_reason() { let (mut app, _) = app_with_frame(); @@ -801,6 +824,69 @@ mod tests { let e = crate::dialogs::confirm(&mut app).unwrap_err(); assert!(e.contains("already named"), "{e}"); assert!(app.ui.dialog.as_ref().is_some_and(|d| d.s("status") == e)); + // New Object Style with a name another style has makes no style under another name. + app.ui.dialog = None; + let count = |app: &DesignApp| app.session.active().unwrap().doc.styles.object.len(); + let before = count(&app); + open_new(&mut app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Taken")); + } + let e = crate::dialogs::confirm(&mut app).unwrap_err(); + assert!(e.contains("already named"), "{e}"); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.s("status") == e)); + assert_eq!(count(&app), before, "no style was made"); + } + + fn app_with_text_frame(units: &str) -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("document.preferences", json!({"horizontalUnits": units, "verticalUnits": units})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 336, 336], "content": "text", "caret": false})).unwrap(); + app.run("selection.set", json!({"ids": [r["id"]]})).unwrap(); + app + } + + /// In a document measured in millimeters, a bare number in a Text Frame General Options field + /// is millimeters, in New Object Style and in Object Style Options. + #[test] + fn bare_numbers_are_in_the_documents_ruler_unit() { + let mut app = app_with_text_frame("millimeters"); + let mm = 72.0 / 25.4; + let frame = |app: &DesignApp| app.session.active().unwrap().doc.styles.object_style("Metric").and_then(|s| s.text_frame.clone()).unwrap(); + open_new(&mut app); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Metric")); + for k in ["insetTop", "insetLeft", "insetBottom", "insetRight"] { + d.fields.insert(k.into(), json!("6")); + } + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!(frame(&app).inset.iter().all(|v| (v - 6.0 * mm).abs() < 1e-6), "{:?}", frame(&app).inset); + open_edit(&mut app, "Metric").unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("columns".into(), json!("2")); + d.fields.insert("gutter".into(), json!("5")); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!((frame(&app).gutter - 5.0 * mm).abs() < 1e-6, "{}", frame(&app).gutter); + } + + /// Object Style Options keeps a General measurement the user did not edit exactly as the style + /// has it, though the field shows it rounded. + #[test] + fn general_measurements_left_alone_keep_their_value() { + let mut app = app_with_text_frame("points"); + let tf = json!({"gutter": 2.123456, "inset": [1.23456, 2.34567, 3.45678, 4.56789]}); + app.run("style.object.create", json!({"name": "Precise", "fromSelection": false, "textFrame": tf})).unwrap(); + open_edit(&mut app, "Precise").unwrap(); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Precise Too")); + d.fields.insert("insetTop".into(), json!("7 pt")); + } + crate::dialogs::confirm(&mut app).unwrap(); + let o = app.session.active().unwrap().doc.styles.object_style("Precise Too").and_then(|s| s.text_frame.clone()).unwrap(); + assert_eq!((o.gutter, o.inset), (2.123456, [7.0, 2.34567, 3.45678, 4.56789])); } /// Object Style Options and New Object Style act only on the document they opened for. With diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index fac694ace..3b2d19e1e 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -110,15 +110,21 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { fn object_style_of_selection(app: &DesignApp) -> Option<(String, bool)> { let st = app.session.active()?; let it = st.selection.items.first().and_then(|i| st.doc.item(*i))?; + Some((it.object_style.clone(), overrides_its_style(&st.doc, it))) +} + +/// Whether `it` differs from its object style: its own attributes, or a text frame's paragraphs +/// in other styles than the object style gives them. +fn overrides_its_style(doc: &designcraft_doc::Document, it: &designcraft_doc::Item) -> bool { + let Some(os) = doc.styles.object_style(&it.object_style) else { return false }; let mut styled = it.clone(); - let mut over = false; - if let Some(os) = st.doc.styles.object_style(&it.object_style) { - os.apply_to(&mut styled); - if let Some(tf) = it.text_frame() { - over = st.doc.story(tf.story).is_some_and(|s| !os.story_follows(&st.doc.styles, s)); - } - } - Some((it.object_style.clone(), over || styled != *it)) + os.apply_to(&mut styled); + styled != *it || it.text_frame().is_some_and(|tf| doc.story(tf.story).is_some_and(|s| !os.story_follows(&doc.styles, s))) +} + +/// Whether any selected object differs from its object style (Clear Overrides clears them all). +fn selection_overrides_styles(app: &DesignApp) -> bool { + app.session.active().is_some_and(|st| st.selection.items.iter().filter_map(|i| st.doc.item(*i)).any(|it| overrides_its_style(&st.doc, it))) } /// Window › Styles › Object Styles, as InDesign's: the selected object's style on top (`+` when @@ -188,7 +194,7 @@ pub fn object(app: &mut DesignApp, ui: &mut egui::Ui) { crate::object_style_dialog::open_new(app); } } - let over = current.as_ref().is_some_and(|c| c.1); + let over = selection_overrides_styles(app); if ui.add_enabled_ui(over, |ui| icons::button(ui, "style-clear", 20.0, false, crate::i18n::tr(&lang, "Clear Overrides"))).inner.clicked() && over { @@ -409,6 +415,40 @@ mod tests { assert!(styles.contains(&"Object Style 1".to_string()), "{styles:?}"); } + /// Clear Overrides is on when any selected frame differs from its style, not only the first; + /// the label on top describes the first. + #[test] + fn clear_overrides_is_on_when_a_later_selected_frame_is_overridden() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let frame = |app: &mut DesignApp, x: f64| { + app.run("frame.create", json!({"rect": [x, 36, x + 100.0, 136], "content": "graphic"})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (frame(&mut app, 36.0), frame(&mut app, 200.0)); + app.run("style.object.create", json!({"name": "Framed", "fromSelection": false, "stroke": {"swatch": "[Paper]", "weight": 3}})).unwrap(); + app.run("style.object.apply", json!({"name": "Framed", "ids": [a, b]})).unwrap(); + app.run("object.stroke", json!({"weight": 1, "ids": [b]})).unwrap(); + app.run("selection.set", json!({"ids": [a, b]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::unique("test_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; + } + object(app, ui); + }, + app, + ); + h.run_steps(4); + assert_eq!(h.get_all_by_label("Framed").count(), 2, "the first frame follows its style"); + h.get_by_label("Clear Overrides").click(); + h.run_steps(3); + let weight = |h: &Harness| h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(b)).unwrap().stroke.weight; + assert_eq!(weight(&h), 3.0); + } + #[test] fn paragraph_color_override_is_marked_and_cleared_from_the_row_menu() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 5fb75aaefcf71d57e328c581b8602c08c97ed7ec Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:27:08 -0800 Subject: [PATCH 5/6] Properties: Text Frame section shows inset spacing and vertical justification (#2661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Properties: Text Frame section shows inset spacing and vertical justification Properties › Text Frame now shows the frame's inset spacing under the columns: Top and Bottom, a link that makes all four the same, then Left and Right. A non-rectangular frame shows one Inset, the distance its text keeps from the outline. Four buttons set Vertical Justification to Top, Center, Bottom or Justify. Type on a path shows neither. The section also shows with the Type tool in a frame. There it changes the frame that shows the insertion point, or every frame that holds part of the selected text, as Object › Text Frame Options does. * Properties: one Inset for a non-rectangular frame keeps the other sides The single Inset sets only the top inset, the one kept from the outline. A rectangular frame that holds part of the same selected text keeps its own sides. * Properties: the one Inset field and the composer share the frame outline test FrameOutline::of_item builds an item's outline with its corner options applied. The composer uses it to keep the top inset from the outline, and the Properties panel uses it to decide whether a frame shows one Inset, so the two give the same answer for every frame. * Text Frame Options: frames taken while typing stay with their document OK is refused when another document became active after the dialog opened, so the saved frame ids do not change frames in that document. * Properties: the inset link starts linked again after Revert The link's state is kept per document state instead of per document, so a frame with equal insets in the reverted document starts linked. * Text Frame Options: frames taken while typing are refused after Revert Revert keeps the document's id but replaces its state, so a new frame can reuse a saved frame id. The dialog now ties the frames to the document state, and OK is refused after Revert as it is in another document. * Properties: the one Inset of a non-rectangular frame sets all four sides The composer bounds a shaped frame's text by its rectangle less all four insets as well as by the outline, so a single Inset that changed only the top left hidden sides in place: lowering a uniform 96 pt inset to 0 kept text overset. When every frame the field changes is shaped, it now sets all four sides. With a rectangular frame among them, only the top inset changes and each frame keeps its other sides. * Fix text frame dialog merge resolution * Keep text frame preview targets fixed * Fix target field key inference * Tests: Text Frame Options show four sides for text through an oval and a rectangle With the Type tool and text selected through an oval and a threaded rectangle, with the oval as the caret's stored frame, the dialog shows the four inset sides, a changed Top leaves the rectangle's other sides as they were, and the link stays off while the rectangle's sides are uneven. * Properties: the inset link starts off when another frame has uneven sides With selected text that runs through several frames, the link of Text Frame > Inset Spacing started linked when the frame the section shows had four equal insets, and a changed Top then set all four sides of every frame. The link now starts linked only when every frame the field changes has four equal insets. Its state is kept for that set of frames, so a link turned on for one frame does not carry over to selected text that also runs through a frame with uneven sides. --------- Co-authored-by: Dylan M. Taylor --- ROADMAP.md | 2 + crates/compose/src/lib.rs | 5 +- crates/compose/src/outline.rs | 7 + crates/engine/src/cmd/object.rs | 1 - crates/ui-egui/src/dialogs.rs | 6 + crates/ui-egui/src/dialogs/tests.rs | 55 +++- crates/ui-egui/src/icons.rs | 27 ++ crates/ui-egui/src/panels/properties.rs | 9 +- crates/ui-egui/src/panels/properties/tests.rs | 269 +++++++++++++++++- crates/ui-egui/src/panels/properties/text.rs | 132 ++++++++- docs/target-app-parity.md | 1 + 11 files changed, 496 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9e83d73ae..ee0f4e022 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -394,12 +394,14 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-12** · Properties › Text Frame shows the frame's inset spacing (Top, Bottom, Left and Right with a link that makes all four the same; one Inset for a non-rectangular frame) and four Vertical Justification buttons (Top, Center, Bottom, Justify), and the section also shows with the Type tool in a frame, where it changes the frame that shows the insertion point. No readiness percentage change. - **2026-11-12** · An edit of a long threaded story composes the paragraphs it can change and copies the other lines from the composition before the edit; every typed character composed the whole story. In a 240-paragraph story a typed character composes 3 paragraphs (240 before). An edit that moves lines into other frames composes on until a paragraph starts in the same frame and place as before, or to the story's end, and stories with footnotes, tables, anchored objects, balanced columns or keep options that move lines are composed whole. No readiness percentage change. - **2026-11-12** · Center, Bottom and Justify vertical justification in a shaped frame test each moved line against the outline down to its baseline. The descenders of earlier lines may cross the outline, as the last line's do, so an outline that narrows under an earlier line no longer holds the text higher by that line's descent. No readiness percentage change. - **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change. - **2026-11-12** · A placed JPEG drawn at a half, a quarter or an eighth of its pixel size or less is decoded at that size straight from its compressed blocks, so the canvas, page thumbnails and loaded-graphic thumbnails no longer decode and keep the full-size picture for it. CMYK JPEGs and the full size still decode in full. No readiness percentage change. - **2026-11-12** · A decoded placed image becomes a screen pixmap in its own buffer, premultiplied in place, and opaque pixels are left as they are: 120 ms becomes 7 ms for a 24-megapixel opaque photo, with the same pixels. No readiness percentage change. - **2026-11-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. No readiness percentage change. + - **2026-11-12** · Object › Text Frame Options… (Cmd+B) opens with the Type tool in a frame, so inset spacing and vertical justification can be changed while typing; it changes the frame that shows the insertion point, or every frame that holds part of the selected text (#2396). No readiness percentage change. - **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change. - **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index a367e7ade..333959626 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1205,10 +1205,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId // area (all four insets) still bounds the lines. let outline = match &tf.options.path { Some(_) => None, - None => { - let bp = if item.corners.is_none() { item.path.to_bezpath() } else { designcraft_geom::corners::apply(&item.path, &item.corners) }; - FrameOutline::new(&bp, tf.options.inset[0]) - } + None => FrameOutline::of_item(item, tf.options.inset[0]), }; // Vertical type: composed in the turned box; wraps and page rects turned with it. let vertical = doc.frame_vertical(item); diff --git a/crates/compose/src/outline.rs b/crates/compose/src/outline.rs index bfc709710..dd1f59c0e 100644 --- a/crates/compose/src/outline.rs +++ b/crates/compose/src/outline.rs @@ -59,6 +59,13 @@ impl FrameOutline { Some(FrameOutline { edges, inset }) } + /// The outline of `item`'s path with its corner options applied, or None when that is the + /// item's own rectangle. The composer and the Properties panel's one Inset field both use it. + pub fn of_item(item: &designcraft_doc::Item, inset: f64) -> Option { + let bp = if item.corners.is_none() { item.path.to_bezpath() } else { designcraft_geom::corners::apply(&item.path, &item.corners) }; + FrameOutline::new(&bp, inset) + } + /// The outline mapped by `xf` (into a vertical frame's turned box). pub fn transformed(&self, xf: Affine) -> FrameOutline { FrameOutline { edges: self.edges.iter().map(|&(a, b)| (xf * a, xf * b)).collect(), inset: self.inset } diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index c269e4f1d..c41564047 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2732,7 +2732,6 @@ fn content_type(s: &mut Session, p: &Value) -> Result { /// The most columns a text frame takes. const MAX_FRAME_COLUMNS: u64 = 40; - /// The frames `object.textFrameOptions` changes when the call names none: the selected objects, /// or with the Type tool the frame that shows the caret or every frame holding part of the /// selected text. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a5babb120..409943f75 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1173,6 +1173,12 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { m.insert("inset".into(), json!(format_measure(o.inset[0], units))); } d.fields.insert("_shaped".into(), json!(shaped)); + if let Some(targets) = typing_frame_ids(app) { + d.fields.entry("_targets").or_insert(targets); + if let Some(epoch) = app.session.active().map(|doc| doc.identity_epoch) { + d.fields.entry("_targetsDoc").or_insert(json!(epoch)); + } + } if let Value::Object(m) = &shown { for (k, v) in m { d.fields.entry(k.clone()).or_insert_with(|| v.clone()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 0bad3ce0e..ca057856d 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1690,6 +1690,47 @@ fn text_frame_options_show_four_sides_for_a_mixed_selection() { assert!(d.b("_shaped")); } +/// With the Type tool, text selected through an oval and a threaded rectangle shows the four +/// sides even when the caret's frame is the oval, and a changed side leaves the rectangle's other +/// sides as they were. +#[test] +fn text_frame_options_show_four_sides_for_text_through_an_oval_and_a_rectangle() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let oval = app.run("frame.create", json!({"rect": [36, 36, 236, 136], "shape": "ellipse", "content": "text", "text": text})).unwrap(); + let sid = oval["story"].as_u64().unwrap(); + let oval = designcraft_doc::ItemId(oval["id"].as_u64().unwrap()); + let rect = app.run("frame.create", json!({"rect": [36, 200, 336, 700], "content": "text", "text": "", "caret": false})).unwrap(); + let rect = designcraft_doc::ItemId(rect["id"].as_u64().unwrap()); + app.run("text.thread", json!({"from": oval.0, "to": rect.0})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [oval.0, rect.0], "inset": [6, 2, 3, 4]})).unwrap(); + let opts = |app: &DesignApp, id| app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.clone(); + let select_through_both = |app: &mut DesignApp| { + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + // The caret's stored frame is the oval. + if let Some(t) = app.session.active_mut().unwrap().selection.text.as_mut() { + t.frame = Some(oval); + } + assert_eq!(designcraft_engine::cmd::text_frame_options_frames(&app.session), vec![oval, rect], "the selected text runs through both frames"); + }; + select_through_both(&mut app); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + assert!(!d.b("_shaped") && !d.fields.contains_key("inset"), "text through a rectangle shows the four sides"); + d.fields.insert("insetTop".into(), json!("1p0")); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + assert_eq!(opts(&app, rect).inset, [12.0, 2.0, 3.0, 4.0]); + assert_eq!(opts(&app, oval).inset, [12.0, 2.0, 3.0, 4.0]); + // An even oval does not link the sides while the rectangle's sides are uneven. + app.run("object.textFrameOptions", json!({"ids": [oval.0], "inset": [5, 5, 5, 5]})).unwrap(); + select_through_both(&mut app); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + assert!(!d.b("insetLinked"), "a frame with uneven sides among the targets leaves the link off"); +} + /// The dialog dims Top, Left, Bottom, Right and the link for a non-rectangular frame and adds an /// Inset field; for a rectangle it shows no Inset field and the sides stay editable. #[test] @@ -4350,12 +4391,20 @@ fn retype_beside(h: &mut egui_kittest::Harness<'static, crate::test_window::Wind h.run_steps(3); } +/// The dialog's "Make all settings the same" links, in reading order: the Properties panel's +/// own links (its Text Frame insets, with a caret in a frame) are to the right of its Quick +/// Actions heading's left edge and are left out. +fn dialog_links(h: &egui_kittest::Harness<'static, crate::test_window::Window>) -> Vec { + use egui_kittest::kittest::Queryable; + let panel_left = h.query_by_label("Quick Actions").map_or(f32::INFINITY, |n| n.rect().left()); + h.get_all_by_label("Make all settings the same").map(|n| n.rect()).filter(|r| r.center().x < panel_left).collect() +} + /// The course's chapter 40: Paragraph Border's four weights are linked as InDesign's, so a /// side typed in sets all four; with the link off ("Make all settings the same") a side is set /// alone, as the lesson gives the border only a thick left side. The corners link the same way. #[test] fn paragraph_border_sides_and_corners_link_as_indesigns() { - use egui_kittest::kittest::Queryable; let (app, _) = ruled_paragraph(); let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "border", "p.borderOn": true}))); @@ -4370,7 +4419,7 @@ fn paragraph_border_sides_and_corners_link_as_indesigns() { // The link of the weights (0), corners (1) or offsets (2), where it is now: the dialog // scrolls as fields take focus. let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + let links = dialog_links(h); assert_eq!(links.len(), 3, "weights, corners and offsets each have a link"); links[n].center() }; @@ -4427,7 +4476,7 @@ fn paragraph_shading_page_is_laid_out_as_indesigns() { assert_eq!(field(&h, "shadingOverprint"), json!(true)); // The corners' link (0) and the offsets' (1). let link = |h: &egui_kittest::Harness<'static, crate::test_window::Window>, n: usize| { - let links: Vec = h.get_all_by_label("Make all settings the same").map(|n| n.rect()).collect(); + let links = dialog_links(h); assert_eq!(links.len(), 2, "corners and offsets each have a link"); links[n].center() }; diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c8eeee911..e15c90ec9 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -997,6 +997,19 @@ fn paint_text(pen: &mut Pen, name: &str) -> bool { pen.line(&[(12.0, 3.5), (12.0, 16.5)]); pen.line(&[(9.0, 10.0), (11.0, 10.0)]); } + "vjust-top" | "vjust-center" | "vjust-bottom" | "vjust-justify" => { + // A frame with three lines of text at its top, middle or bottom, or spread over it. + pen.rect(2.5, 2.5, 17.5, 17.5); + let ys = match name { + "vjust-top" => [5.5, 8.0, 10.5], + "vjust-center" => [7.5, 10.0, 12.5], + "vjust-bottom" => [9.5, 12.0, 14.5], + _ => [5.5, 10.0, 14.5], + }; + for y in ys { + pen.line(&[(5.0, y), (15.0, y)]); + } + } "small-caps" => { // Two original T shapes share a baseline; no phonetic small-cap glyph is required. pen.line(&[(3.0, 5.0), (10.0, 5.0)]); @@ -1383,6 +1396,20 @@ mod tests { } } + /// Each Vertical Justification button in Properties › Text Frame has its own glyph. + #[test] + fn vertical_justification_glyphs_are_distinct() { + let names = ["vjust-top", "vjust-center", "vjust-bottom", "vjust-justify"]; + let unknown = drawing("no-such-icon"); + let glyphs: Vec = names.iter().map(|n| drawing(n)).collect(); + for (k, g) in glyphs.iter().enumerate() { + assert_ne!(*g, unknown, "{} falls back to the placeholder", names[k]); + for (j, other) in glyphs.iter().enumerate().skip(k + 1) { + assert_ne!(g, other, "{} and {} draw alike", names[k], names[j]); + } + } + } + #[test] fn distribute_icons_are_distinct_from_align_and_each_other() { // `objalign-*` ship upstream and back the Properties Align row; only the diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 9b949f547..b6dabfe65 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -38,7 +38,8 @@ pub use object_panels::{align_panel, attributes_panel, effects_panel, pathfinder use object_panels::{anchor_point_sections, pathfinder_row}; pub(crate) use text::{LANGUAGES, kerning_control, language_menu}; use text::{ - bullets_section, character_section, paragraph_section, quick_actions_text, text_appearance_section, text_frame_section, text_style_section, + bullets_section, character_section, paragraph_section, quick_actions_text, text_appearance_section, text_frame_section, text_frame_target, + text_style_section, }; pub use typography::{DIGIT_OPTIONS, character_panel, digit_label, paragraph_panel, show_typography_panel}; #[cfg(test)] @@ -137,6 +138,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { section(ui, crate::i18n::tr(&app.ui.language, "Bullets and Numbering"), true); bullets_section(app, ui); divider(ui); + // The frame that shows the caret, as with the frame selected. + if text_frame_target(app).is_some() { + section(ui, crate::i18n::tr(&app.ui.language, "Text Frame"), true); + text_frame_section(app, ui); + divider(ui); + } section(ui, crate::i18n::tr(&app.ui.language, "Quick Actions"), true); quick_actions_text(app, ui); return; diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 59e0cbd1e..1792e3b3a 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -798,7 +798,9 @@ fn bullets_options_open_the_bullets_and_numbering_dialog() { let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs"})).unwrap(); app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 6})).unwrap(); let mut h = panel(app); - h.get_by_label("Options").click(); + // Bullets and Numbering's Options, above Text Frame's. + let bullets = h.get_all_by_label("Options").min_by(|a, b| a.rect().top().total_cmp(&b.rect().top())).unwrap(); + bullets.click(); h.run_steps(3); let d = h.state().ui.dialog.clone().expect("a dialog"); assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "bullets")); @@ -1278,6 +1280,269 @@ fn transform_flips_and_rotates_from_properties() { assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); } +/// The vertical extent of Properties › Text Frame: from its heading (the last "Text Frame" label; +/// the selection band above may say "Text Frame" too) to the Quick Actions heading. +fn text_frame_band(h: &Harness<'_, DesignApp>) -> (f32, f32) { + let top = h.get_all_by_label("Text Frame").map(|n| n.rect().bottom()).fold(f32::MIN, f32::max); + (top, h.get_by_label("Quick Actions").rect().top()) +} + +/// The text fields of Properties › Text Frame, in reading order. +fn text_frame_fields(h: &Harness<'_, DesignApp>) -> Vec { + let (top, bottom) = text_frame_band(h); + let mut v: Vec = + h.get_all_by_role(egui::accesskit::Role::TextInput).map(|n| n.rect()).filter(|r| r.center().y > top && r.center().y < bottom).collect(); + v.sort_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()); + v +} + +/// Clicks the control labeled `label` inside Properties › Text Frame. +fn click_in_text_frame(h: &mut Harness<'_, DesignApp>, label: &str) { + let (top, bottom) = text_frame_band(h); + h.get_all_by_label(label).find(|n| n.rect().center().y > top && n.rect().center().y < bottom).unwrap().click(); + h.run_steps(3); +} + +fn type_in_field(h: &mut Harness<'_, DesignApp>, at: egui::Rect, text: &str) { + h.hover_at(at.center()); + h.drag_at(at.center()); + h.drop_at(at.center()); + 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(text.into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); +} + +fn frame_options(app: &DesignApp, id: u64) -> designcraft_doc::TextFrameOptions { + app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().text_frame().unwrap().options.clone() +} + +/// Properties › Text Frame shows the frame's inset spacing under the columns, linked while the +/// four insets are the same, and the four vertical justification buttons. Each change is one +/// undo step; the button that is already on adds none. +#[test] +fn properties_text_frame_sets_inset_spacing_and_vertical_justification() { + use designcraft_doc::VerticalJustification; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + // Columns and gutter, then Top and Left, then Bottom and Right. + let f = text_frame_fields(&h); + assert_eq!(f.len(), 6, "columns, gutter and four insets: {f:?}"); + let steps = undo_steps(h.state()); + // Linked: the top inset sets all four. + type_in_field(&mut h, f[2], "12pt"); + assert_eq!(frame_options(h.state(), id).inset, [12.0; 4]); + assert_eq!(undo_steps(h.state()), steps + 1); + // Unlinked: the bottom one alone. + click_in_text_frame(&mut h, "Make all settings the same"); + let f = text_frame_fields(&h); + type_in_field(&mut h, f[4], "6pt"); + assert_eq!(frame_options(h.state(), id).inset, [12.0, 12.0, 6.0, 12.0], "[top, left, bottom, right]"); + // Vertical justification. + assert_eq!(frame_options(h.state(), id).vertical_justification, VerticalJustification::Top); + for (label, vj) in + [("Bottom", VerticalJustification::Bottom), ("Center", VerticalJustification::Center), ("Justify", VerticalJustification::Justify)] + { + click_in_text_frame(&mut h, label); + assert_eq!(frame_options(h.state(), id).vertical_justification, vj, "{label}"); + } + let steps = undo_steps(h.state()); + click_in_text_frame(&mut h, "Justify"); + assert_eq!(undo_steps(h.state()), steps, "the button already on adds no undo step"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(frame_options(h.state(), id).vertical_justification, VerticalJustification::Center); +} + +/// Insets unlinked before Revert start linked again in the reverted document, whose frame has the +/// same id and four equal insets. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn properties_text_frame_inset_link_starts_linked_after_revert() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + let dir = tempfile::tempdir().unwrap(); + app.run("file.saveAs", json!({"path": dir.path().join("revert.designcraft")})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + click_in_text_frame(&mut h, "Make all settings the same"); + h.state_mut().run("frame.create", json!({"rect": [320, 72, 400, 150], "content": "text", "text": "unsaved"})).unwrap(); + h.state_mut().run("file.revert", json!({})).unwrap(); + h.state_mut().run("selection.set", json!({"ids": [id]})).unwrap(); + h.run_steps(3); + let f = text_frame_fields(&h); + type_in_field(&mut h, f[4], "6pt"); + assert_eq!(frame_options(h.state(), id).inset, [6.0; 4], "linked: the bottom inset sets all four"); +} + +/// With the Type tool in the second frame of a thread, Properties shows the Text Frame section, +/// and its insets and vertical justification change that frame and not the first. +#[test] +fn properties_text_frame_shows_while_typing_and_changes_the_caret_frame() { + use designcraft_doc::VerticalJustification; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap(); + let b = app.run("frame.create", json!({"rect": [36, 200, 300, 700], "content": "text", "text": "", "caret": false})).unwrap(); + let (a, b, sid) = (a["id"].as_u64().unwrap(), b["id"].as_u64().unwrap(), a["story"].as_u64().unwrap()); + app.run("text.thread", json!({"from": a, "to": b})).unwrap(); + // The end of the story is in the second frame. + app.run("text.select", json!({"story": sid, "anchor": text.len()})).unwrap(); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 6, "columns, gutter and four insets while typing: {f:?}"); + type_in_field(&mut h, f[2], "9pt"); + click_in_text_frame(&mut h, "Bottom"); + let (oa, ob) = (frame_options(h.state(), a), frame_options(h.state(), b)); + assert_eq!((oa.inset, ob.inset), ([0.0; 4], [9.0; 4])); + assert_eq!((oa.vertical_justification, ob.vertical_justification), (VerticalJustification::Top, VerticalJustification::Bottom)); + assert!(h.state().session.active().unwrap().selection.text.is_some(), "the caret stays in the text"); +} + +/// A non-rectangular text frame shows one Inset, which sets the distance its text keeps from +/// the outline. The composer also bounds its text by all four insets, so the field sets all four. +#[test] +fn properties_text_frame_shows_one_inset_for_an_oval() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "shape": "ellipse", "content": "text", "text": "Hello"})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 3, "columns, gutter and one inset: {f:?}"); + h.state_mut().run("object.textFrameOptions", json!({"ids": [id], "inset": [1, 2, 3, 4]})).unwrap(); + h.run_steps(2); + type_in_field(&mut h, f[2], "8pt"); + assert_eq!(frame_options(h.state(), id).inset, [8.0; 4]); +} + +/// Lowering an oval's uniform inset with the one Inset field lowers every side, so no hidden +/// side keeps the old inset and the text area that goes with it. +#[test] +fn properties_text_frame_one_inset_lowers_a_uniform_inset_on_every_side() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "shape": "ellipse", "content": "text", "text": "Hello"})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("object.textFrameOptions", json!({"ids": [id], "inset": 96})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 3, "columns, gutter and one inset: {f:?}"); + type_in_field(&mut h, f[2], "0pt"); + assert_eq!(frame_options(h.state(), id).inset, [0.0; 4]); +} + +/// With selected text that runs from an oval into a rectangular frame, the one Inset field +/// changes the top inset of both, and each frame keeps its other sides. +#[test] +fn properties_text_frame_one_inset_keeps_the_sides_of_a_rectangle_in_the_selection() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let a = app.run("frame.create", json!({"rect": [72, 72, 300, 300], "shape": "ellipse", "content": "text", "text": text})).unwrap(); + let b = app.run("frame.create", json!({"rect": [72, 400, 300, 760], "content": "text", "text": "", "caret": false})).unwrap(); + let (oval, rect, sid) = (a["id"].as_u64().unwrap(), b["id"].as_u64().unwrap(), a["story"].as_u64().unwrap()); + app.run("text.thread", json!({"from": oval, "to": rect})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [oval, rect], "inset": [1, 2, 3, 4]})).unwrap(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert_eq!( + designcraft_engine::cmd::text_frame_options_frames(&app.session), + [designcraft_doc::ItemId(oval), designcraft_doc::ItemId(rect)], + "the selected text runs through both frames" + ); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 3, "columns, gutter and one inset for the first frame, an oval: {f:?}"); + type_in_field(&mut h, f[2], "8pt"); + assert_eq!(frame_options(h.state(), oval).inset, [8.0, 2.0, 3.0, 4.0]); + assert_eq!(frame_options(h.state(), rect).inset, [8.0, 2.0, 3.0, 4.0]); +} + +/// With selected text that runs through two rectangular frames, the inset link starts off when +/// one of them has uneven sides, even while the frame the section shows has four equal insets, so +/// a changed Top leaves the other sides of each frame as they were. +#[test] +fn properties_text_frame_inset_link_starts_off_when_another_frame_has_uneven_sides() { + for even_first in [true, false] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap(); + let b = app.run("frame.create", json!({"rect": [36, 200, 300, 700], "content": "text", "text": "", "caret": false})).unwrap(); + let (a, b, sid) = (a["id"].as_u64().unwrap(), b["id"].as_u64().unwrap(), a["story"].as_u64().unwrap()); + app.run("text.thread", json!({"from": a, "to": b})).unwrap(); + let (even, uneven) = if even_first { (a, b) } else { (b, a) }; + app.run("object.textFrameOptions", json!({"ids": [even], "inset": 5})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [uneven], "inset": [1, 2, 3, 4]})).unwrap(); + app.run("text.select", json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap(); + assert_eq!( + designcraft_engine::cmd::text_frame_options_frames(&app.session), + [designcraft_doc::ItemId(a), designcraft_doc::ItemId(b)], + "the selected text runs through both frames" + ); + let mut h = panel(app); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 6, "columns, gutter and four insets: {f:?}"); + type_in_field(&mut h, f[2], "8pt"); + assert_eq!(frame_options(h.state(), even).inset, [8.0, 5.0, 5.0, 5.0], "even frame first: {even_first}"); + assert_eq!(frame_options(h.state(), uneven).inset, [8.0, 2.0, 3.0, 4.0], "even frame first: {even_first}"); + } +} + +/// A link turned on for one frame does not carry over to selected text that runs from that frame +/// into a frame with uneven sides: the link starts off there, and a changed Top leaves the other +/// sides as they were. +#[test] +fn properties_text_frame_inset_link_of_one_frame_does_not_carry_to_a_text_selection() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap(); + let b = app.run("frame.create", json!({"rect": [36, 200, 300, 700], "content": "text", "text": "", "caret": false})).unwrap(); + let (a, b, sid) = (a["id"].as_u64().unwrap(), b["id"].as_u64().unwrap(), a["story"].as_u64().unwrap()); + app.run("text.thread", json!({"from": a, "to": b})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [a], "inset": 5})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [b], "inset": [1, 2, 3, 4]})).unwrap(); + app.run("selection.set", json!({"ids": [a]})).unwrap(); + let mut h = panel(app); + // Off, then on again: the link of the first frame alone is now turned on by hand. + click_in_text_frame(&mut h, "Make all settings the same"); + click_in_text_frame(&mut h, "Make all settings the same"); + // The caret at the start of the story is in the first frame; the selection runs into the second. + h.state_mut().run("text.select", json!({"story": sid, "anchor": text.len(), "focus": 0})).unwrap(); + h.run_steps(3); + let f = text_frame_fields(&h); + assert_eq!(f.len(), 6, "columns, gutter and four insets: {f:?}"); + type_in_field(&mut h, f[2], "8pt"); + assert_eq!(frame_options(h.state(), a).inset, [8.0, 5.0, 5.0, 5.0]); + assert_eq!(frame_options(h.state(), b).inset, [8.0, 2.0, 3.0, 4.0]); +} + +/// Type on a path sets one line along the path: Properties › Text Frame shows no insets and no +/// vertical justification buttons for it. +#[test] +fn properties_text_frame_shows_no_insets_for_type_on_a_path() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "shape": "ellipse"})).unwrap()["id"].as_u64().unwrap(); + app.run("type.onPath", json!({"id": id, "text": "Around"})).unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + assert!(frame_options(&app, id).path.is_some()); + let h = panel(app); + assert_eq!(text_frame_fields(&h).len(), 2, "columns and gutter only"); + let (top, bottom) = text_frame_band(&h); + assert!(h.query_all_by_label("Justify").all(|n| n.rect().center().y < top || n.rect().center().y > bottom)); +} + /// With the Type tool, the Text Frame section shows the frame Text Frame Options change: the frame /// the caret is drawn in, not the caret's stored frame. #[test] @@ -1294,6 +1559,6 @@ fn text_frame_section_shows_the_frame_that_shows_the_caret() { // The end of the story is drawn in the second frame; the caret's stored frame is the first. app.session.doc_mut().unwrap().selection = Selection::text(TextSel { story: StoryId(sid), anchor: text.len(), focus: text.len(), frame: Some(ItemId(a)), cell: None }); - let shown = super::text::text_frame_target(&app).map(|(id, o)| (id, o.columns)); + let shown = super::text::text_frame_target(&app).map(|(id, o, _)| (id, o.columns)); assert_eq!(shown, Some((ItemId(b), 3))); } diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index f444d6ba2..94e3b4ef8 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -517,20 +517,31 @@ pub(super) fn quick_actions_text(app: &mut DesignApp, ui: &mut Ui) { } /// The text frame the Text Frame section shows, as Text Frame Options shows it: the first selected -/// frame, the frame that shows the caret, or the first frame holding selected text. Its id and its -/// options. -pub(super) fn text_frame_target(app: &DesignApp) -> Option<(designcraft_doc::ItemId, designcraft_doc::TextFrameOptions)> { +/// frame, the frame that shows the caret, or the first frame holding selected text. Its id, its +/// options, and whether its outline is not its own rectangle (an oval, a polygon or corner +/// options), where the text keeps one inset from the outline. Type on a path is not shaped. +pub(super) fn text_frame_target(app: &DesignApp) -> Option<(designcraft_doc::ItemId, designcraft_doc::TextFrameOptions, bool)> { let st = app.session.active()?; let fid = designcraft_engine::cmd::text_frame_options_frames(&app.session).first().copied()?; - Some((fid, st.doc.item(fid)?.text_frame()?.options.clone())) + let item = st.doc.item(fid)?; + let opts = item.text_frame()?.options.clone(); + Some((fid, opts, is_shaped(item))) } -/// Text Frame section: columns, gutter, [Options]. With the Type tool it changes the frame that -/// shows the caret, or every frame that holds part of the selected text. +/// Whether a text frame's outline is not its own rectangle, where its text keeps one inset from +/// the outline. Type on a path is not shaped. +fn is_shaped(item: &designcraft_doc::Item) -> bool { + item.text_frame().is_some_and(|tf| tf.options.path.is_none()) && designcraft_compose::FrameOutline::of_item(item, 0.0).is_some() +} + +/// Text Frame section: columns, gutter, [Options]; Inset Spacing (Top and Bottom, the link that +/// makes all four the same, Left and Right; one Inset for a non-rectangular frame); the four +/// Vertical Justification buttons (neither for type on a path). With the Type tool it changes the +/// frame that shows the caret, or every frame that holds part of the selected text. pub(super) fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let u = units(app); - let Some((_, opts)) = text_frame_target(app) else { return }; + let Some((fid, opts, shaped)) = text_frame_target(app) else { return }; let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 0.0, 1.0, 19.0, 19.0), "text-columns", t.icon); if let Some(v) = place(ui, sub(b, 22.0, 0.0, 54.0, FIELD_H), |ui| { @@ -548,6 +559,113 @@ pub(super) fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) { if place(ui, sub(b, 164.0, 0.5, ow, 20.0), |ui| outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), ow)).clicked() { app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); } + // Type on a path sets one line along the path: insets and vertical justification do not apply. + if opts.path.is_some() { + return; + } + text_frame_insets(app, ui, fid, &opts, shaped); + ui.add_space(4.0); + let cur = opts.vertical_justification; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (vj, icon, tip) in [ + (designcraft_doc::VerticalJustification::Top, "vjust-top", "Top"), + (designcraft_doc::VerticalJustification::Center, "vjust-center", "Center"), + (designcraft_doc::VerticalJustification::Bottom, "vjust-bottom", "Bottom"), + (designcraft_doc::VerticalJustification::Justify, "vjust-justify", "Justify"), + ] { + if icon_toggle(ui, icon, cur == vj, crate::i18n::tr(&app.ui.language, tip)).clicked() { + let _ = app.run("object.textFrameOptions", json!({"verticalJustification": vj})); + } + } + }); +} + +/// Inset Spacing under the columns: Top and Bottom, the link that makes all four the same, then +/// Left and Right, as the wrap offsets are laid out. A non-rectangular frame shows one Inset, the +/// distance its text keeps from the outline. +fn text_frame_insets(app: &mut DesignApp, ui: &mut Ui, fid: designcraft_doc::ItemId, opts: &designcraft_doc::TextFrameOptions, shaped: bool) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language.clone(); + let tr = |s: &str| crate::i18n::tr(&lang, s).to_string(); + let u = units(app); + let inset = opts.inset; + ui.add_space(4.0); + if shaped { + let b = block(ui, FIELD_H); + let ir = sub(b, 0.0, 2.0, 17.0, 17.0); + icons::paint(ui.painter(), ir, "margin-top", t.icon); + ui.interact(ir, ui.scope_id().with("prop_tf_inset"), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr("Inset")); + }); + if let Some(v) = place(ui, sub(b, 20.0, 0.0, 76.0, FIELD_H), |ui| { + NumField::measure("tfInset", Some(inset[0]), u).width(76.0).spinner().range(0.0, 15552.0).show(ui) + }) { + // The composer also bounds the text by the frame's rectangle less all four insets, so + // when every frame it changes is shaped the one Inset sets all four. With a + // rectangular frame among them (part of the same selected text), only the top inset + // changes and each frame keeps its other sides. + let all_shaped = app.session.active().is_some_and(|st| { + designcraft_engine::cmd::text_frame_options_frames(&app.session) + .iter() + .filter_map(|id| st.doc.item(*id)) + .filter(|it| it.text_frame().is_some_and(|tf| tf.options.path.is_none())) + .all(is_shaped) + }); + let inset = if all_shaped { json!(v) } else { json!([v, null, null, null]) }; + let _ = app.run("object.textFrameOptions", json!({"inset": inset})); + } + return; + } + let b = block(ui, 2.0 * FIELD_H + 5.0); + // The link's state belongs to the frames the field changes in this document state: other + // frames, or frames with the same ids in another document or after Revert, start linked when + // every one of them has four equal insets, so a frame with uneven sides keeps them when one + // side changes. + let doc_epoch = app.session.active().map_or(0, |st| st.identity_epoch); + let targets = designcraft_engine::cmd::text_frame_options_frames(&app.session); + let target_ids: Vec = targets.iter().map(|id| id.0).collect(); + let link_id = egui::Id::unique(("prop_tf_inset_linked", doc_epoch, fid.0, target_ids)); + let even = |i: &[f64; 4]| i.iter().all(|v| (v - i[0]).abs() < 1e-9); + let same = even(&inset) + && app.session.active().is_none_or(|st| targets.iter().filter_map(|id| st.doc.item(*id)?.text_frame()).all(|tf| even(&tf.options.inset))); + let linked = ui.data(|d| d.get_temp::(link_id)).unwrap_or(same); + let mut set = None; + // Insets are [top, left, bottom, right]. + for (k, key, icon, tip, x, y) in [ + (0, "tfInsetTop", "margin-top", "Top", 0.0, 0.0), + (2, "tfInsetBottom", "margin-bottom", "Bottom", 0.0, FIELD_H + 5.0), + (1, "tfInsetLeft", "margin-inside", "Left", 140.0, 0.0), + (3, "tfInsetRight", "margin-outside", "Right", 140.0, FIELD_H + 5.0), + ] { + 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(("prop_tf_inset", k)), Sense::hover()).on_hover_ui(|ui| { + crate::rtl::label(ui, tr(tip)); + }); + let nv = place(ui, sub(b, x + 20.0, y, 76.0, FIELD_H), |ui| { + NumField::measure(key, Some(inset[k]), u).width(76.0).spinner().range(0.0, 15552.0).show(ui) + }); + if let Some(v) = nv { + // Linked, all four; else this side alone. + let mut o = [None; 4]; + if linked { + o = [Some(v); 4]; + } else { + o[k] = Some(v); + } + set = Some(o); + } + } + if place(ui, sub(b, 106.0, (FIELD_H + 5.0) / 2.0, 22.0, FIELD_H), |ui| { + widgets::icon_toggle_sized(ui, if linked { "link" } else { "link-broken" }, linked, &tr("Make all settings the same"), vec2(22.0, 21.0)) + .clicked() + }) { + ui.data_mut(|d| d.insert_temp(link_id, !linked)); + } + if let Some(o) = set { + let _ = app.run("object.textFrameOptions", json!({"inset": o})); + } } /// Languages offered for text (InDesign-style names). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 608b587f8..52bd45ddc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -353,6 +353,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Auto-size (Text Frame Options › Auto-Size, `object.textFrameOptions {autoSize: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference, useMinimumHeight, minimumHeight, useMinimumWidth, minimumWidth, noLineBreaks}`): the frame fits its text exactly after every edit (typing, pasting, style changes, option changes) in the same undo step (opening a file keeps the frame sizes it gives); it grows and shrinks around the reference point, which stays put in spread space for rotated frames too, never below the minimum height/width and never past 10,000 pt. Width Only and No Line Breaks set each paragraph on one line; Height and Width without No Line Breaks keeps the width; Keep Proportions scales both sides until the text fits. IDML keeps all the options (`AutoSizing…`, `…ForAutoSizing` attributes). Not covered: frames of a thread other than the last, vertical frames, type on a path, and multi-column balancing in Keep Proportions. - Balance Columns (Text Frame Options › Balance Columns, `object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`): in the frame the story ends in, the columns after the last explicit column break fill to about the same depth (the last may be shorter by a line per column); keep options hold, spanning and split paragraphs keep their own balancing, vertical justification then applies to each column as usual, and vertical frames balance their bands. A frame the text runs on from, or that is overset, fills normally. - Text Frame Options from the Type tool (Object › Text Frame Options…, Cmd+B, `object.textFrameOptions` without `ids`): with the insertion point in a frame, the options change the frame it is drawn in; with text selected, every frame that holds part of it. Options a frame already has add no undo step. +- Properties › Text Frame (DesignCraft's own layout): columns and gutter; Inset Spacing with Top, Bottom, Left and Right and a link that makes all four the same (one Inset for a non-rectangular frame); four Vertical Justification buttons (Top, Center, Bottom, Justify); neither insets nor vertical justification for type on a path; Options opens Text Frame Options. With the Type tool in a frame the section shows too and changes the frame that shows the insertion point, or every frame that holds part of the selected text (`object.textFrameOptions {inset, verticalJustification}`). - Text Frame Options dialog, laid out as InDesign's (General, Column Rules, Baseline Options, Auto-Size; framed Columns, Inset Spacing and Vertical Justification groups with spinner fields; Fixed Number shows each column's width as the fields change). Preview shows the edits on the page until OK (one undo step) or Cancel. An invalid value (such as a gutter that isn't a measure) takes the preview off the page, so the frame shows as it was, and the dialog's status line gives the reason; OK refuses the value too. Preview stays checked and shows the edits again once the value is valid. Vertical Justification › Paragraph Spacing Limit (`object.textFrameOptions {paragraphSpacingLimit}`, IDML `VerticalThreshold`): with Justify, the extra space goes between paragraphs up to the limit, the rest between lines. - Text Frame Options › Inset Spacing for a non-rectangular frame (an oval, a polygon, a frame with corner options; when every selected text frame is one, a mixed selection keeps the four sides): Top, Left, Bottom, Right and the link are dimmed, and one Inset field sets the distance kept between the text and the frame's outline (`object.textFrameOptions {inset: }`, all four sides). - Place a page of an IDML or DesignCraft document (`file.place {layoutPage}`): its objects, stories, styles and swatches as one group (INDD files are not readable). From 67a5cb273ef40ff3190900bd2e2d2df6241d41e4 Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Thu, 12 Nov 2026 14:27:13 -0800 Subject: [PATCH 6/6] Tables: table commands are disabled with the caret in footnote text (#2706) Footnote text is addressed as a cell, so with the insertion point in a footnote the table commands (Insert Row, Delete Column, Merge Cells, Table Options, Select Table, table styles) were enabled and then failed with "no table 18446744073709551615". They are now disabled with "place the insertion point in a table", as the cell commands already were. The caret's footnote offset is no longer looked up in the main story either, where it could land on a table's paragraph. table.get and the other commands that take any selection report the same message instead of the footnote address. A call that names a table still runs. --- ROADMAP.md | 1 + crates/engine/src/cmd/table/target.rs | 5 ++- crates/engine/src/tests/table.rs | 45 +++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index ee0f4e022..c06cfceaa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -400,6 +400,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m - **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change. - **2026-11-12** · A placed JPEG drawn at a half, a quarter or an eighth of its pixel size or less is decoded at that size straight from its compressed blocks, so the canvas, page thumbnails and loaded-graphic thumbnails no longer decode and keep the full-size picture for it. CMYK JPEGs and the full size still decode in full. No readiness percentage change. - **2026-11-12** · A decoded placed image becomes a screen pixmap in its own buffer, premultiplied in place, and opaque pixels are left as they are: 120 ms becomes 7 ms for a 24-megapixel opaque photo, with the same pixels. No readiness percentage change. +- **2026-11-12** · With the insertion point in footnote text, the table commands (Insert Row, Delete Column, Merge Cells, Table Options, Select Table, table styles) are disabled, as they are outside a table, instead of failing with "no table 18446744073709551615". A call that names a table still runs. No readiness percentage change. - **2026-11-12** · The status bar's preflight count and the Preflight panel take their issues from a check on a background worker, which checks the newest state of the document and shows the issues last found meanwhile. Moving or resizing a frame and typing no longer wait for a check of the whole document on every drag step and keystroke. No readiness percentage change. - **2026-11-12** · Object › Text Frame Options… (Cmd+B) opens with the Type tool in a frame, so inset spacing and vertical justification can be changed while typing; it changes the frame that shows the insertion point, or every frame that holds part of the selected text (#2396). No readiness percentage change. diff --git a/crates/engine/src/cmd/table/target.rs b/crates/engine/src/cmd/table/target.rs index 0240aff23..a939a09dd 100644 --- a/crates/engine/src/cmd/table/target.rs +++ b/crates/engine/src/cmd/table/target.rs @@ -8,10 +8,12 @@ use super::{ pub(super) fn in_table(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; let st = s.active().ok_or("no document open")?; - if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some()) { + // Footnote text is addressed as a cell too, but isn't in a table. + if st.selection.cells.is_some() || st.selection.text.is_some_and(|t| t.cell.is_some_and(|c| c.footnote_id().is_none())) { return Ok(()); } if let Some(t) = st.selection.text + && t.cell.is_none() && st.doc.story(t.story).is_some_and(|x| x.table_at(t.focus).is_some()) { return Ok(()); @@ -81,6 +83,7 @@ pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result { (c.story, c.table, Some(c.range)) } else if let Some(t) = st.selection.text { match t.cell { + Some(c) if c.footnote_id().is_some() => return Err(bad(cmd, "place the insertion point in a table")), Some(c) => (t.story, c.table, Some(CellRange::cell(c.row, c.col))), None => { let tid = doc.story(t.story).and_then(|x| x.table_at(t.focus)).ok_or_else(|| bad(cmd, "the insertion point is not in a table"))?; diff --git a/crates/engine/src/tests/table.rs b/crates/engine/src/tests/table.rs index 7626005f4..fd5323ffb 100644 --- a/crates/engine/src/tests/table.rs +++ b/crates/engine/src/tests/table.rs @@ -643,6 +643,51 @@ fn a_named_table_edit_keeps_a_footnote_caret() { check(&s); } +/// A caret in footnote text is not in a table: the table commands that need one are disabled +/// there, also when the caret's offset in the footnote falls on a table's paragraph in the main +/// story, and a call that names a table still runs. +#[test] +fn table_commands_are_disabled_in_footnote_text() { + let in_table = ["table.insertRow", "table.deleteColumn", "table.merge", "table.options", "table.selectTable", "style.table.apply"]; + let info = |s: &Session, id: &str| crate::cmd::find_command(id).unwrap().info(s); + for on_table in [false, true] { + let (mut s, sid, _) = session_with_frame(); + let tid = if on_table { + s.execute("story.setText", &json!({"story": sid, "text": "Hello"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let end = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.len(); + s.execute("text.select", &json!({"story": sid, "anchor": end, "focus": end})).unwrap(); + s.execute("footnote.insert", &json!({})).unwrap(); + tid + } else { + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("footnote.insert", &json!({"text": "Note"})).unwrap(); + tid + }; + let before = s.doc().unwrap().selection.clone(); + let caret = before.text.unwrap(); + assert!(caret.cell.and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote"); + let story = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap(); + assert_eq!(story.table_at(caret.focus).is_some(), on_table, "focus {}", caret.focus); + for id in in_table { + let i = info(&s, id); + assert!(!i.enabled, "{id} {on_table}"); + assert_eq!(i.disabled_reason.as_deref(), Some("place the insertion point in a table"), "{id} {on_table}"); + assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id} {on_table}"); + } + // A query that takes any selection and names no table is told where to put the caret. + let error = s.execute("table.get", &json!({})).unwrap_err().to_string(); + assert!(error.contains("place the insertion point in a table"), "{on_table}: {error}"); + assert_eq!(s.doc().unwrap().selection, before); + s.execute("table.insertRow", &json!({"table": tid})).unwrap(); + assert_eq!(s.execute("table.get", &json!({"table": tid})).unwrap()["rows"].as_array().unwrap().len(), 3); + assert_eq!(s.doc().unwrap().selection, before); + check(&s); + } +} + /// With nothing selected, the table commands stay disabled for menus and for calls that name no /// table. #[test]