mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-14 14:22:39 +00:00
Merge latest upstream into parallel image decode
This commit is contained in:
22 files changed
+968
-76
No files matched your search
@@ -395,11 +395,15 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
## Progress log
|
||||
|
||||
- **2026-11-12** · A render decodes the placed images in view that are not cached yet on several threads before it draws, instead of one after another while drawing. Page thumbnails, image exports and the first canvas frame of a page with six 24 MP photos take about 1.2 s instead of 2.8 s. No readiness percentage change.
|
||||
- **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** · 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.
|
||||
- **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.
|
||||
|
||||
+12
-12
@@ -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);
|
||||
@@ -6066,12 +6063,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 +6094,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
|
||||
@@ -6307,9 +6306,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) {
|
||||
|
||||
@@ -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<FrameOutline> {
|
||||
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 }
|
||||
|
||||
+188
-3
@@ -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]
|
||||
@@ -3019,13 +3057,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 +3093,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
|
||||
@@ -9622,6 +9687,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,
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -265,19 +281,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let kind: Option<designcraft_doc::StrokeType> = 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()?;
|
||||
@@ -2728,7 +2732,6 @@ fn content_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -182,7 +182,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -243,11 +243,17 @@ fn bool_param(cmd: &str, p: &Value, key: &str) -> Result<Option<bool>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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<Option<Option<designcraft_doc::Stroke>>> {
|
||||
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<Value> {
|
||||
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 {
|
||||
|
||||
@@ -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<Tgt> {
|
||||
(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"))?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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::<Vec<_>>().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<egui::Rect> {
|
||||
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<egui::Rect> = 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<egui::Rect> = 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()
|
||||
};
|
||||
|
||||
@@ -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<String> = 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
|
||||
|
||||
@@ -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<Option<designcraft_doc::TextFrameOptions>
|
||||
}
|
||||
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<Value, String> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<egui::Rect> {
|
||||
let (top, bottom) = text_frame_band(h);
|
||||
let mut v: Vec<egui::Rect> =
|
||||
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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().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)));
|
||||
}
|
||||
@@ -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<u64> = 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::<bool>(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).
|
||||
|
||||
@@ -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::<bool>(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<DesignApp>| 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());
|
||||
|
||||
@@ -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: <number>}`, 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).
|
||||
|
||||
Reference in new issue
Block a user