From 510269744ac3cc0b9bd9a3bab307ce30d5bf7b65 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 18:21:39 -0800 Subject: [PATCH 1/6] Text: Text Frame Options open from the Type tool and change the frame holding the caret Object > Text Frame Options and Cmd+B are enabled with a text caret or a text selection. Without ids or selected objects, object.textFrameOptions changes the frame that shows the insertion point, or every frame that holds part of the selected text, instead of every frame of the story. The dialog shows that frame's options and keeps the frames it opened with while the preview reflows the text. An empty ids list and an id that is not in the document are refused. Options a frame already has leave the document unchanged and add no undo step. Fixes #2396 --- ROADMAP.md | 1 + crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/object.rs | 267 +++++++++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 73 ++++++-- crates/ui-egui/src/menus/tests.rs | 27 +++ docs/mcp.md | 9 + docs/target-app-parity.md | 1 + 7 files changed, 359 insertions(+), 20 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 006396b1d..42a160c94 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -387,6 +387,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-12** · Object › Text Frame Options… (Cmd+B) opens with the Type tool in a frame, so inset spacing and vertical justification can be changed while typing; it changes the frame that shows the insertion point, or every frame that holds part of the selected text (#2396). No readiness percentage change. - **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change. - **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change. diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index dc1acaf15..4fdd375e0 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -41,6 +41,7 @@ mod mathexpr; mod media; mod notes; mod object; +pub use object::text_frame_options_frames; mod overrides; mod package; pub mod params; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 634c90a61..67b1d21d4 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -610,8 +610,8 @@ pub fn specs() -> Vec { "Text Frame Options…", ["Object"], Some("Cmd+B"), - "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids?}", - has_selection, + "{columns?, gutter?, inset?: number|[t,l,b,r] (null keeps that side), verticalJustification?: top|center|bottom|justify, paragraphSpacingLimit? (pt: Justify adds at most this between paragraphs, the rest between lines), columnsKind?: fixedNumber|fixedWidth, columnWidth? (pt, Fixed Width), firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin? (pt), autoSize?: off|heightOnly|widthOnly|heightAndWidth|heightAndWidthProportionally, autoSizeReference?: topLeft|topCenter|topRight|leftCenter|center|rightCenter|bottomLeft|bottomCenter|bottomRight (the point that stays put), useMinimumHeight?: bool, minimumHeight? (pt), useMinimumWidth?: bool, minimumWidth? (pt), noLineBreaks?: bool, ignoreWrap?, balanceColumns?, columnRule?: bool, columnRuleWeight? (pt), columnRuleColor? (swatch name), columnRuleTint? (0..1), columnRuleOffset? (pt, horizontal), columnRuleTopInset? (pt), columnRuleBottomInset? (pt), baselineGrid?: {start? (pt), increment? (pt), relativeTo?: page|margin|frame|inset} (the frame's custom baseline grid; fields not given keep the frame's, or come from the document grid; null turns it off), vertical?: bool (sets the story direction of the frames' stories, as Type ▸ Story Direction), ids? (without ids or selected objects: the frame showing the text caret, or every frame holding part of the selected text)}", + has_selection_or_text, text_frame_options ), cmd!( @@ -2569,14 +2569,38 @@ fn content_type(s: &mut Session, p: &Value) -> Result { }) } +/// 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. +pub fn text_frame_options_frames(s: &Session) -> Vec { + let Ok(st) = s.doc() else { return Vec::new() }; + if !st.selection.items.is_empty() { + return st.selection.items.clone(); + } + st.selection.text.map(|t| text_selection_frames(s, &t)).unwrap_or_default() +} + +/// Text Frame Options act on the selected objects, or with the Type tool on the frames that hold +/// the caret or the selected text. +fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| d.selection.text.is_some()) { + return Ok(()); + } + has_selection(s) +} + fn text_frame_options(s: &mut Session, p: &Value) -> Result { const ID: &str = "object.textFrameOptions"; let st = s.doc()?; - let mut ids = targets(s, p)?; - if ids.is_empty() - && let Some(t) = st.selection.text - { - ids = st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default(); + // A call that names its objects acts on those; an empty `ids` names nothing. + let named = p.get("ids").is_some() || p.get("id").is_some(); + let ids = if named { targets(s, p)? } else { text_frame_options_frames(s) }; + // With a text caret the command runs before named objects are checked. + if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) { + return Err(bad(ID, format!("no object with id {}", gone.0))); + } + if ids.is_empty() { + return Err(bad(ID, super::NOTHING_SELECTED)); } let ranged = |key: &str, lo: f64, hi: f64, what: &str| match p.get(key) { None | Some(Value::Null) => Ok(None), @@ -2644,8 +2668,10 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { Some(Value::String(c)) if st.doc.swatch(c).is_some() => Some(c.clone()), Some(v) => return Err(bad(ID, format!("no swatch {v}"))), }; + let (before, revision) = (Arc::clone(&st.doc), st.revision); + let find = s.find_context.take(); let p = p.clone(); - s.edit(|d, _| { + let edited = s.edit(|d, _| { for id in &ids { let Some(tf) = d.item_mut(*id).and_then(Item::text_frame_mut) else { continue }; let o = &mut tf.options; @@ -2738,6 +2764,7 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { if fixed_width && ["columnsKind", "columnWidth", "columns", "gutter", "inset"].iter().any(|k| p.get(*k).is_some()) && let Some(r) = d.item(*id).map(Item::inner_bounds) + && ((r.width() - width).abs() >= 1e-9 || !width.is_finite()) { let r = super::finite_rect("object.textFrameOptions", Rect::new(r.x0, r.y0, r.x0 + width, r.y1))?; crate::autosize::resize(d, *id, r); @@ -2749,7 +2776,71 @@ fn text_frame_options(s: &mut Session, p: &Value) -> Result { super::text::set_story_direction(d, &stories, v); } Ok(json!({"changed": ids.len()})) - }) + }); + let changed = match edited { + Ok(v) => v, + Err(e) => { + s.find_context = find; + return Err(e); + } + }; + // Options the frames already have: the document, its revision and the Find Next scope go back + // to what they were, which leaves no undo step. Only the frames and their stories are compared. + let st = s.doc_mut()?; + let same = ids.iter().all(|id| { + let (now, was) = (st.doc.item(*id), before.item(*id)); + now == was && now.and_then(Item::text_frame).is_none_or(|t| st.doc.story(t.story) == before.story(t.story)) + }); + if same { + st.doc = before; + st.revision = revision; + s.find_context = find; + return Ok(json!({"changed": 0})); + } + Ok(changed) +} + +/// The frames Text Frame Options acts on from the Type tool: the frame that shows the caret, or +/// every frame that holds part of the selected text. Text past the last frame counts as the last +/// frame's. +fn text_selection_frames(s: &Session, t: &TextSel) -> Vec { + let Ok(st) = s.doc() else { return Vec::new() }; + let Some(story) = st.doc.story(t.story) else { return Vec::new() }; + let last = story.frames.last().copied(); + // In a table cell, `anchor` and `focus` index the cell's text: the frame the caret is shown in. + if t.cell.is_some() { + return t.frame.filter(|f| story.frames.contains(f)).or(last).into_iter().collect(); + } + let cs = s.cache.get(&st.doc, t.story, None); + let r = t.range(); + // The frame the caret at the start is drawn in; the caret's stored frame only when no line + // holds it. + let caret_frame = || { + designcraft_compose::caret(&cs, r.start) + .and_then(|(fi, ..)| cs.frames.get(fi)) + .map(|f| f.frame) + .or_else(|| t.frame.filter(|f| story.frames.contains(f) && cs.overset_at.is_none_or(|o| r.start < o))) + }; + // A frame's range stops before the paragraph return that ends its last line; the return + // belongs to that frame. + let end_of = |f: &designcraft_compose::FrameText| f.range.end + usize::from(story.text.as_bytes().get(f.range.end) == Some(&b'\n')); + let mut frames: Vec = if t.is_caret() { + caret_frame().into_iter().collect() + } else { + cs.frames.iter().filter(|f| f.range.start < r.end && r.start < end_of(f)).map(|f| f.frame).collect() + }; + // Selected text no frame holds goes with the line before it. + if frames.is_empty() { + frames.extend(caret_frame()); + } + let overset = cs.overset_at.is_some_and(|o| r.end > o); + if let Some(l) = last + && (frames.is_empty() || overset) + && !frames.contains(&l) + { + frames.push(l); + } + frames } fn apply_custom_frame_grid( @@ -4820,6 +4911,7 @@ mod named_target_tests { s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap(); assert!(s.doc().unwrap().selection.items.is_empty(), "frame.create leaves a text caret"); + s.execute("edit.deselectAll", &json!({})).unwrap(); let e = s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap_err().to_string(); assert!(e.contains("nothing selected"), "{e}"); s.execute("object.textFrameOptions", &json!({"ids": [r["id"]], "columns": 2})).unwrap(); @@ -4839,6 +4931,163 @@ mod named_target_tests { } } +#[cfg(test)] +mod type_tool_frame_options_tests { + use super::*; + use designcraft_doc::VerticalJustification; + + /// One story threaded through two frames, with text in both: (first frame, second frame, story). + fn two_frame_thread(s: &mut Session) -> (ItemId, ItemId, StoryId) { + s.execute("file.new", &json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [36, 200, 300, 700], "content": "text", "text": ""})).unwrap(); + let (a, b) = (ItemId(a["id"].as_u64().unwrap()), ItemId(b["id"].as_u64().unwrap())); + s.execute("text.thread", &json!({"from": a.0, "to": b.0})).unwrap(); + let sid = s.doc().unwrap().doc.item(a).and_then(Item::text_frame).unwrap().story; + (a, b, sid) + } + + /// The story bytes `frame` shows. + fn shown(s: &Session, sid: StoryId, frame: ItemId) -> std::ops::Range { + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); + cs.frame(frame).unwrap().range.clone() + } + + fn options(s: &Session, id: ItemId) -> TextFrameOptions { + s.doc().unwrap().doc.item(id).and_then(Item::text_frame).unwrap().options.clone() + } + + fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + /// With the Type tool, Text Frame Options change the frame that shows the caret, not every + /// frame of the story. + #[test] + fn a_caret_changes_the_frame_it_is_in() { + let mut s = Session::new(); + let (a, b, sid) = two_frame_thread(&mut s); + let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b)); + assert!(!ra.is_empty() && !rb.is_empty(), "both frames hold text: {ra:?} {rb:?}"); + s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 2})).unwrap(); + assert!(s.doc().unwrap().selection.items.is_empty()); + let steps = undo_steps(&s); + s.execute("object.textFrameOptions", &json!({"columns": 3, "verticalJustification": "center", "inset": 6})).unwrap(); + assert_eq!( + (options(&s, b).columns, options(&s, b).vertical_justification, options(&s, b).inset), + (3, VerticalJustification::Center, [6.0; 4]) + ); + assert_eq!((options(&s, a).columns, options(&s, a).vertical_justification), (1, VerticalJustification::Top), "the first frame is left alone"); + assert_eq!(undo_steps(&s), steps + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(options(&s, b).columns, 1); + // A caret in the first frame changes the first frame. + s.execute("text.select", &json!({"story": sid.0, "anchor": ra.start + 2})).unwrap(); + s.execute("object.textFrameOptions", &json!({"verticalJustification": "bottom"})).unwrap(); + assert_eq!( + (options(&s, a).vertical_justification, options(&s, b).vertical_justification), + (VerticalJustification::Bottom, VerticalJustification::Top) + ); + } + + /// A caret moved to the start of the second frame keeps the first frame as its stored frame; + /// the options go to the frame the caret is drawn in. + #[test] + fn a_caret_at_a_frame_boundary_changes_the_frame_it_is_drawn_in() { + let mut s = Session::new(); + let (a, b, sid) = two_frame_thread(&mut s); + let rb = shown(&s, sid, b); + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); + let drawn = designcraft_compose::caret(&cs, rb.start).and_then(|(fi, ..)| cs.frames.get(fi)).map(|f| f.frame); + assert_eq!(drawn, Some(b), "the caret at the boundary is drawn in the second frame"); + s.doc_mut().unwrap().selection = Selection::text(TextSel { story: sid, anchor: rb.start, focus: rb.start, frame: Some(a), cell: None }); + s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap(); + assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 2)); + } + + /// Selected text that runs from one frame into the next changes both frames. + #[test] + fn selected_text_changes_every_frame_it_runs_through() { + let mut s = Session::new(); + let (a, b, sid) = two_frame_thread(&mut s); + let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b)); + s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end - 3, "focus": rb.start + 3})).unwrap(); + s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap(); + assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 2)); + // Text inside one frame changes that frame only. + s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 1, "focus": rb.start + 6})).unwrap(); + s.execute("object.textFrameOptions", &json!({"columns": 4})).unwrap(); + assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 4)); + } + + /// A selected paragraph return at the end of the first frame changes the first frame. + #[test] + fn a_selected_return_at_the_end_of_a_frame_changes_that_frame() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let text = (0..40).map(|i| format!("Paragraph {i}")).collect::>().join("\n"); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [36, 200, 300, 700], "content": "text", "text": ""})).unwrap(); + let (a, b) = (ItemId(a["id"].as_u64().unwrap()), ItemId(b["id"].as_u64().unwrap())); + s.execute("text.thread", &json!({"from": a.0, "to": b.0})).unwrap(); + let sid = s.doc().unwrap().doc.item(a).and_then(Item::text_frame).unwrap().story; + let (ra, rb) = (shown(&s, sid, a), shown(&s, sid, b)); + assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text.as_bytes()[ra.end], b'\n', "the first frame ends before a return"); + assert_eq!(rb.start, ra.end + 1, "the second frame starts after it"); + s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end, "focus": ra.end + 1})).unwrap(); + s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap(); + assert_eq!((options(&s, a).columns, options(&s, b).columns), (2, 1)); + // From that return into the second frame, with the first frame back as it was: both frames. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((shown(&s, sid, a), shown(&s, sid, b)), (ra.clone(), rb.clone())); + s.execute("text.select", &json!({"story": sid.0, "anchor": ra.end, "focus": rb.start + 3})).unwrap(); + s.execute("object.textFrameOptions", &json!({"columns": 3})).unwrap(); + assert_eq!((options(&s, a).columns, options(&s, b).columns), (3, 3)); + } + + /// An empty `ids` names no frame, so it does not fall back to the caret's frame. + #[test] + fn empty_ids_with_a_caret_is_an_error() { + let mut s = Session::new(); + let (a, b, sid) = two_frame_thread(&mut s); + s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let e = s.execute("object.textFrameOptions", &json!({"ids": [], "columns": 3})).unwrap_err().to_string(); + assert!(e.contains("nothing selected"), "{e}"); + // An id that is not in the document is refused too. + let e = s.execute("object.textFrameOptions", &json!({"ids": [99999, b.0], "columns": 3})).unwrap_err().to_string(); + assert!(e.contains("no object with id 99999"), "{e}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 1)); + } + + /// Options the frame already has change nothing and add no undo step. + #[test] + fn unchanged_options_add_no_undo_step() { + let mut s = Session::new(); + let (_, b, sid) = two_frame_thread(&mut s); + let rb = shown(&s, sid, b); + s.execute("text.select", &json!({"story": sid.0, "anchor": rb.start + 2})).unwrap(); + let steps = undo_steps(&s); + let (before, revision) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().revision); + let r = s.execute("object.textFrameOptions", &json!({"columns": 1, "verticalJustification": "top", "inset": 0})).unwrap(); + assert_eq!(r["changed"], 0); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + assert_eq!(s.doc().unwrap().revision, revision, "the revision stays"); + assert_eq!(undo_steps(&s), steps); + // The same holds for selected frames, Fixed Width columns included. + s.execute("selection.set", &json!({"ids": [b.0]})).unwrap(); + s.execute("object.textFrameOptions", &json!({"gutter": options(&s, b).gutter})).unwrap(); + assert_eq!(undo_steps(&s), steps); + let fixed = json!({"columnsKind": "fixedWidth", "columnWidth": 100.3, "columns": 2, "gutter": 12.1}); + s.execute("object.textFrameOptions", &fixed).unwrap(); + assert_eq!(undo_steps(&s), steps + 1); + assert_eq!(s.execute("object.textFrameOptions", &fixed).unwrap()["changed"], 0); + assert_eq!(undo_steps(&s), steps + 1, "the same Fixed Width options again add no undo step"); + } +} + #[cfg(test)] mod column_rule_tests { use super::*; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a6400054d..f2689c927 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -556,6 +556,39 @@ mod color_theme_tests { assert_eq!(opts(&app).columns, 1, "one undo step"); } + /// Opened with the caret on the last line of a frame, Text Frame Options keep acting on that + /// frame while a preview pushes the caret's line into the next frame of the thread. + #[test] + fn text_frame_options_keep_their_frame_while_the_preview_reflows_the_caret() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let 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) = + (designcraft_doc::ItemId(a["id"].as_u64().unwrap()), designcraft_doc::ItemId(b["id"].as_u64().unwrap()), a["story"].as_u64().unwrap()); + app.run("text.thread", json!({"from": a.0, "to": b.0})).unwrap(); + let shown = |app: &DesignApp, f| { + let cs = designcraft_compose::compose_story(&app.session.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default()); + cs.frame(f).unwrap().range.clone() + }; + let caret = shown(&app, a).end - 1; + app.run("text.select", json!({"story": sid, "anchor": caret})).unwrap(); + let inset = |app: &DesignApp, f| app.session.doc().unwrap().doc.item(f).unwrap().text_frame().unwrap().options.inset[0]; + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + d.fields.insert("inset".into(), json!("2p0")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(&mut app, &mut d); + assert!(shown(&app, b).contains(&caret), "the preview moved the caret's line into the next frame"); + d.fields.insert("columns".into(), json!("2")); + text_frame_preview(&mut app, &mut d); + assert_eq!((inset(&app, a), inset(&app, b)), (24.0, 0.0), "the preview stays on the first frame"); + app.ui.dialog = Some(d); + confirm(&mut app).unwrap(); + assert_eq!((inset(&app, a), inset(&app, b)), (24.0, 0.0)); + } + #[test] fn toc_right_indent_separator_survives_the_dialog_and_update() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -991,18 +1024,22 @@ fn pdf_export(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } } +/// The first frame Text Frame Options changes: the first selected item, the frame that shows the +/// caret, or the first frame holding selected text. +fn options_frame(app: &DesignApp) -> Option { + designcraft_engine::cmd::text_frame_options_frames(&app.session).first().copied() +} + /// The selected text frame's width (its own, before rotation). fn selected_frame_width(app: &DesignApp) -> Option { let st = app.session.active()?; - let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?; - Some(st.doc.item(fid)?.inner_bounds().width()) + Some(st.doc.item(options_frame(app)?)?.inner_bounds().width()) } -/// The options of the selected text frame (the first selected item, or the frame of the caret). +/// The options of the frame Text Frame Options shows. fn selected_frame_options(app: &DesignApp) -> Option { let st = app.session.active()?; - let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame))?; - st.doc.item(fid)?.text_frame().map(|t| t.options.clone()) + st.doc.item(options_frame(app)?)?.text_frame().map(|t| t.options.clone()) } /// Object › Text Frame Options shows the selected frame's options. `_shown` keeps what it showed, @@ -1061,9 +1098,25 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { if let Some(w) = selected_frame_width(app) { d.fields.insert("_frameWidth".into(), json!(w)); } + // Typing in a frame: the frames taken when the dialog opens, so a preview that reflows the + // text into another frame does not change which frames the options apply to. + if let Some(ids) = typing_frame_ids(app) { + d.fields.insert("_targets".into(), ids); + } d.fields.insert("_shown".into(), shown); } +/// With the Type tool and no frame selected: the frame that shows the caret, or every frame that +/// holds part of the selected text. +fn typing_frame_ids(app: &DesignApp) -> Option { + let st = app.session.active()?; + if !st.selection.items.is_empty() || st.selection.text.is_none() { + return None; + } + let ids: Vec = designcraft_engine::cmd::text_frame_options_frames(&app.session).iter().map(|f| f.0).collect(); + Some(json!(ids)) +} + /// A 0..1 tint as a percentage ("40", "12.5"). fn percent(t: f32) -> String { let s = format!("{:.2}", f64::from(t) * 100.0); @@ -1552,12 +1605,10 @@ fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, json!(d.m("paragraphSpacingLimit").filter(|v| *v >= 0.0).ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?), ); } - // Typing in a frame: the options apply to that frame. - if let Some(st) = app.session.active() - && st.selection.items.is_empty() - && let Some(f) = st.selection.text.and_then(|t| t.frame) - { - p.insert("ids".into(), json!([f.0])); + // Typing in a frame: the options apply to the frame that showed the caret, or to every frame + // that held part of the selected text, when the dialog opened. + if let Some(ids) = d.fields.get("_targets").cloned().or_else(|| typing_frame_ids(app)) { + p.insert("ids".into(), ids); } Ok(p) } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index e977c6733..c3b679b1b 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -335,6 +335,33 @@ fn pdf_export_menu_item_opens_the_dialog_or_exports() { assert!(app.ui.dialog.is_none()); } +/// With the Type tool in the second frame of a thread, Cmd+B opens Text Frame Options, and OK +/// changes the frame that shows the caret. +#[test] +fn text_frame_options_open_from_the_type_tool() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let 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(); + assert!(super::state::enabled(&app, "object.textFrameOptions"), "the menu item is enabled while typing"); + let ctx = egui::Context::default(); + press_keys(&mut app, &ctx, &[(egui::Key::B, egui::Modifiers::COMMAND)]); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("textFrameOptions"), "Cmd+B opens the dialog while typing"); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("columns".into(), json!(3)); + } + crate::dialogs::confirm(&mut app).unwrap(); + let columns = |app: &crate::DesignApp, id: u64| { + app.session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).and_then(|i| i.text_frame().map(|t| t.options.columns)) + }; + assert_eq!((columns(&app, a), columns(&app, b)), (Some(1), Some(3))); +} + #[test] fn snap_preferences_are_a_command() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/docs/mcp.md b/docs/mcp.md index f7e0b8187..17c7e330b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -225,6 +225,15 @@ execute {"command": "type.para", "params": {"attrs": {"hyphenate": fals With a text selection, this formats the selected paragraphs; with selected text frames, it formats their whole stories. +`object.textFrameOptions` (columns, inset spacing, vertical justification and the rest of Text Frame Options) acts on +the selected frames or on the `ids` the call passes. With the Type tool and no frame selected, it acts on the frame that +shows the insertion point, or on every frame that holds part of the selected text, not on the other frames of the +thread. An empty `ids` is an error. Options a frame already has leave the document unchanged and add no undo step. + +```text +execute {"command": "object.textFrameOptions", "params": {"verticalJustification": "center", "inset": 6}} +``` + Use `execute` with `object.fit` to fit a selected graphic frame (or pass `ids`): | `mode` | What it does | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 193d3ae9a..1f6e0ff14 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -351,6 +351,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Column rules (Text Frame Options › Column Rules, `object.textFrameOptions {columnRule, columnRuleWeight, columnRuleColor, columnRuleTint, columnRuleOffset, columnRuleTopInset, columnRuleBottomInset}`, reported by `document.inspect`): a bar centred in each gutter of a multi-column frame from the top line's ascent to the bottom line's descent, between the columns of right-to-left and vertical frames too, broken around paragraphs that span columns, with weight, colour, tint, horizontal offset and top/bottom insets; on screen, in PDF (tagged as an artifact) and in IDML (`ColumnRule…` attributes of `TextFramePreference`, also in object styles). The dialog shows the frame's options and changes only the fields edited. Rule stroke types and overprint aren't there yet. - 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. - 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. - 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). - Articles panel (`article.new/add/remove/options/delete/list`): reading order and content of EPUB and HTML exports. From 18be5b76f5972905fdc4d4638ef1ab5f126fd143 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:06:29 -0800 Subject: [PATCH 2/6] Text: Text Frame Options in a footnote or table cell change the frame that shows it With the insertion point in footnote text or a table cell and no stored frame on the caret, Text Frame Options changed the last frame of the thread. They now change the frame the caret is drawn in: the frame that holds the placed footnote, or the cell's own occurrence (not a repeated header or footer), else the frame that shows the footnote reference or the table's anchor. --- crates/engine/src/cmd/object.rs | 96 +++++++++++++++++++++++++++++++-- docs/mcp.md | 3 +- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 67b1d21d4..50628d2bc 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2807,11 +2807,24 @@ fn text_selection_frames(s: &Session, t: &TextSel) -> Vec { let Ok(st) = s.doc() else { return Vec::new() }; let Some(story) = st.doc.story(t.story) else { return Vec::new() }; let last = story.frames.last().copied(); - // In a table cell, `anchor` and `focus` index the cell's text: the frame the caret is shown in. - if t.cell.is_some() { - return t.frame.filter(|f| story.frames.contains(f)).or(last).into_iter().collect(); - } let cs = s.cache.get(&st.doc, t.story, None); + // In footnote text or a table cell, `anchor` and `focus` index that text: the frame the caret + // is drawn in (the placed footnote, or the cell's first non-repeated occurrence), else the + // frame that shows the footnote reference or the table's anchor character. + if let Some(c) = t.cell { + let (drawn, anchor) = match c.footnote_id() { + Some(id) => (designcraft_compose::find_note(&cs, id).map(|(fi, _)| fi), story.note_anchor(id)), + None => (designcraft_compose::find_cell(&cs, c.table, c.row, c.col).map(|(fi, ..)| fi), story.table_anchor(c.table)), + }; + return drawn + .or_else(|| anchor.and_then(|a| designcraft_compose::caret(&cs, a)).map(|(fi, ..)| fi)) + .and_then(|fi| cs.frames.get(fi)) + .map(|f| f.frame) + .or_else(|| t.frame.filter(|f| story.frames.contains(f))) + .or(last) + .into_iter() + .collect(); + } let r = t.range(); // The frame the caret at the start is drawn in; the caret's stored frame only when no line // holds it. @@ -5086,6 +5099,81 @@ mod type_tool_frame_options_tests { assert_eq!(s.execute("object.textFrameOptions", &fixed).unwrap()["changed"], 0); assert_eq!(undo_steps(&s), steps + 1, "the same Fixed Width options again add no undo step"); } + + /// A caret in footnote text changes the frame that shows the footnote, with or without a + /// stored frame on the caret. + #[test] + fn a_caret_in_a_footnote_changes_the_frame_that_shows_it() { + for (in_second, stored) in [(false, None), (true, None), (false, Some(true))] { + let mut s = Session::new(); + let (a, b, sid) = two_frame_thread(&mut s); + let r = shown(&s, sid, if in_second { b } else { a }); + s.execute("text.select", &json!({"story": sid.0, "anchor": r.start + 2})).unwrap(); + s.execute("footnote.insert", &json!({"text": "Note."})).unwrap(); + let mut t = s.doc().unwrap().selection.text.unwrap(); + assert!(t.cell.and_then(|c| c.footnote_id()).is_some(), "the caret is in the footnote: {t:?}"); + if let Some(second) = stored { + t.frame = Some(if second { b } else { a }); + s.doc_mut().unwrap().selection = Selection::text(t); + } + s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap(); + let want = if in_second { (1, 2) } else { (2, 1) }; + assert_eq!((options(&s, a).columns, options(&s, b).columns), want, "footnote in the second frame: {in_second}, stored frame: {stored:?}"); + } + } + + /// A caret in a table cell changes the frame that shows the cell, with or without a stored + /// frame on the caret. + #[test] + fn a_caret_in_a_table_cell_changes_the_frame_that_shows_it() { + for (in_second, stored) in [(false, None), (true, None), (false, Some(true))] { + let mut s = Session::new(); + let (a, b, sid) = two_frame_thread(&mut s); + let r = shown(&s, sid, if in_second { b } else { a }); + s.execute("text.select", &json!({"story": sid.0, "anchor": r.start + 2})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap(); + let mut t = s.doc().unwrap().selection.text.unwrap(); + assert!(t.cell.is_some_and(|c| c.footnote_id().is_none()), "the caret is in the cell: {t:?}"); + if let Some(second) = stored { + t.frame = Some(if second { b } else { a }); + s.doc_mut().unwrap().selection = Selection::text(t); + } + s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap(); + let want = if in_second { (1, 2) } else { (2, 1) }; + assert_eq!((options(&s, a).columns, options(&s, b).columns), want, "table in the second frame: {in_second}, stored frame: {stored:?}"); + } + } + + /// A caret in a repeated footer row is drawn in the footer's own occurrence at the end of the + /// table: that frame changes, not the earlier frame that shows a repeat of the footer. + #[test] + fn a_caret_in_a_repeated_footer_changes_the_frame_with_the_footer_itself() { + let mut s = Session::new(); + let (a, b, sid) = two_frame_thread(&mut s); + let ra = shown(&s, sid, a); + s.execute("text.select", &json!({"story": sid.0, "anchor": ra.start + 2})).unwrap(); + s.execute("table.insert", &json!({"rows": 15, "cols": 1, "footerRows": 1})).unwrap(); + s.execute("table.options", &json!({"repeatFooter": true})).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + let table = t.cell.unwrap().table; + let footer = s.doc().unwrap().doc.story(sid).unwrap().tables.get(&table).unwrap().nrows() - 1; + let cs = designcraft_compose::compose_story(&s.doc().unwrap().doc, sid, &Default::default()); + let placed = |f: ItemId| { + cs.frame(f) + .unwrap() + .tables + .iter() + .filter(|tf| tf.table == table) + .filter_map(|tf| tf.cell(footer, 0)) + .map(|c| c.repeated) + .collect::>() + }; + assert_eq!((placed(a), placed(b)), (vec![true], vec![false]), "the footer repeats in the first frame and ends the table in the second"); + s.doc_mut().unwrap().selection = + Selection::text(TextSel { cell: Some(designcraft_doc::CellAddr { table, row: footer, col: 0 }), anchor: 0, focus: 0, frame: None, ..t }); + s.execute("object.textFrameOptions", &json!({"columns": 2})).unwrap(); + assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 2)); + } } #[cfg(test)] diff --git a/docs/mcp.md b/docs/mcp.md index 17c7e330b..d9a15256a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -228,7 +228,8 @@ With a text selection, this formats the selected paragraphs; with selected text `object.textFrameOptions` (columns, inset spacing, vertical justification and the rest of Text Frame Options) acts on the selected frames or on the `ids` the call passes. With the Type tool and no frame selected, it acts on the frame that shows the insertion point, or on every frame that holds part of the selected text, not on the other frames of the -thread. An empty `ids` is an error. Options a frame already has leave the document unchanged and add no undo step. +thread. With the insertion point in a footnote or a table cell, it acts on the frame that shows the footnote or the +cell. An empty `ids` is an error. Options a frame already has leave the document unchanged and add no undo step. ```text execute {"command": "object.textFrameOptions", "params": {"verticalJustification": "center", "inset": 6}} From 3473b8d96ef9d9c0c01398919f3e0b9a0404bab2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:46:20 -0800 Subject: [PATCH 3/6] Text: Text Frame Options refuse an ids that is not a list of object ids A call with an `ids` or `id` that is not an object id used to fall back to the selected frame or the caret's frame. It now fails and leaves the document unchanged. The Properties panel's Text Frame section shows the frame Text Frame Options change: with the Type tool, the frame the caret is drawn in. --- crates/engine/src/cmd/object.rs | 32 +++++++++++++++++-- crates/ui-egui/src/panels/properties/tests.rs | 20 ++++++++++++ crates/ui-egui/src/panels/properties/text.rs | 16 +++++++--- docs/mcp.md | 2 +- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 50628d2bc..e8b3267bc 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -2592,9 +2592,16 @@ fn has_selection_or_text(s: &Session) -> std::result::Result<(), String> { fn text_frame_options(s: &mut Session, p: &Value) -> Result { const ID: &str = "object.textFrameOptions"; let st = s.doc()?; - // A call that names its objects acts on those; an empty `ids` names nothing. - let named = p.get("ids").is_some() || p.get("id").is_some(); - let ids = if named { targets(s, p)? } else { text_frame_options_frames(s) }; + // A call that names its objects acts on those; an empty `ids` names nothing. `ids`, when + // given, wins over `id`, and a value that is not an object id is an error. + let ids = match (p.get("ids"), p.get("id")) { + (Some(v), _) => v + .as_array() + .and_then(|a| a.iter().map(|x| x.as_u64().map(ItemId)).collect::>>()) + .ok_or_else(|| bad(ID, format!("ids must be a list of object ids, not {v}")))?, + (None, Some(v)) => vec![v.as_u64().map(ItemId).ok_or_else(|| bad(ID, format!("id must be an object id, not {v}")))?], + (None, None) => text_frame_options_frames(s), + }; // With a text caret the command runs before named objects are checked. if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) { return Err(bad(ID, format!("no object with id {}", gone.0))); @@ -5075,6 +5082,25 @@ mod type_tool_frame_options_tests { assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 1)); } + /// An `ids` that is not a list of object ids is refused, not read as the selected frame or the + /// caret's frame. + #[test] + fn malformed_ids_are_an_error() { + let mut s = Session::new(); + let (a, b, sid) = two_frame_thread(&mut s); + s.execute("selection.set", &json!({"ids": [b.0]})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + for p in [json!({"ids": "bad", "columns": 2}), json!({"ids": [b.0, "x"], "columns": 2}), json!({"ids": null, "columns": 2})] { + let e = s.execute("object.textFrameOptions", &p).unwrap_err().to_string(); + assert!(e.contains("ids must be a list of object ids"), "{p}: {e}"); + } + s.execute("text.select", &json!({"story": sid.0, "anchor": 2})).unwrap(); + let e = s.execute("object.textFrameOptions", &json!({"ids": "bad", "columns": 2})).unwrap_err().to_string(); + assert!(e.contains("ids must be a list of object ids"), "with a caret: {e}"); + assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "nothing changed"); + assert_eq!((options(&s, a).columns, options(&s, b).columns), (1, 1)); + } + /// Options the frame already has change nothing and add no undo step. #[test] fn unchanged_options_add_no_undo_step() { diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index 1e7c40c8a..59e0cbd1e 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1277,3 +1277,23 @@ fn transform_flips_and_rotates_from_properties() { // The angle and scale fields are there. assert!(h.query_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("90°")).is_some()); } + +/// 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] +fn text_frame_section_shows_the_frame_that_shows_the_caret() { + use designcraft_doc::{ItemId, Selection, StoryId, TextSel}; + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": text})).unwrap(); + let b = app.run("frame.create", json!({"rect": [36, 200, 300, 700], "content": "text", "text": "", "caret": false})).unwrap(); + let (a, b, sid) = (a["id"].as_u64().unwrap(), b["id"].as_u64().unwrap(), a["story"].as_u64().unwrap()); + app.run("text.thread", json!({"from": a, "to": b})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [b], "columns": 3})).unwrap(); + // 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)); + assert_eq!(shown, Some((ItemId(b), 3))); +} diff --git a/crates/ui-egui/src/panels/properties/text.rs b/crates/ui-egui/src/panels/properties/text.rs index 36c23c195..f444d6ba2 100644 --- a/crates/ui-egui/src/panels/properties/text.rs +++ b/crates/ui-egui/src/panels/properties/text.rs @@ -516,13 +516,21 @@ pub(super) fn quick_actions_text(app: &mut DesignApp, ui: &mut Ui) { } } -/// Text Frame section: columns, gutter, [Options]. +/// 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)> { + 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())) +} + +/// 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. pub(super) fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let u = units(app); - let Some(st) = app.session.active() else { return }; - let fid = st.selection.items.first().copied().or_else(|| st.selection.text.and_then(|t| t.frame)); - let Some(opts) = fid.and_then(|f| st.doc.item(f)).and_then(|i| i.text_frame()).map(|t| t.options.clone()) else { return }; + let Some((_, opts)) = 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| { diff --git a/docs/mcp.md b/docs/mcp.md index d9a15256a..255f282a4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -229,7 +229,7 @@ With a text selection, this formats the selected paragraphs; with selected text the selected frames or on the `ids` the call passes. With the Type tool and no frame selected, it acts on the frame that shows the insertion point, or on every frame that holds part of the selected text, not on the other frames of the thread. With the insertion point in a footnote or a table cell, it acts on the frame that shows the footnote or the -cell. An empty `ids` is an error. Options a frame already has leave the document unchanged and add no undo step. +cell. An empty `ids`, or an `ids` that is not a list of object ids, is an error. Options a frame already has leave the document unchanged and add no undo step. ```text execute {"command": "object.textFrameOptions", "params": {"verticalJustification": "center", "inset": 6}} From 2c0121aebb098ccaab6473c8d8eddcc84f81dd5d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 09:14:42 -0800 Subject: [PATCH 4/6] Text: Text Frame Options from the Type tool stay with their document The dialog stores the frames it took from the caret or selection with the document state they came from. OK and Preview are refused when another document became active after the dialog opened, or when Revert replaced the document, since the stored frame ids can name a different frame there. --- crates/ui-egui/src/dialogs.rs | 19 +++++++++-- crates/ui-egui/src/dialogs/tests.rs | 53 +++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f2689c927..b6141a75f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1102,6 +1102,9 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { // text into another frame does not change which frames the options apply to. if let Some(ids) = typing_frame_ids(app) { d.fields.insert("_targets".into(), ids); + // The document state those frames are in: another document, or the document after + // Revert, can reuse their ids. + d.fields.insert("_targetsDoc".into(), json!(app.session.active().map(|doc| doc.identity_epoch))); } d.fields.insert("_shown".into(), shown); } @@ -1605,14 +1608,24 @@ fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, json!(d.m("paragraphSpacingLimit").filter(|v| *v >= 0.0).ok_or_else(|| bad("the paragraph spacing limit is not a measure"))?), ); } - // Typing in a frame: the options apply to the frame that showed the caret, or to every frame - // that held part of the selected text, when the dialog opened. - if let Some(ids) = d.fields.get("_targets").cloned().or_else(|| typing_frame_ids(app)) { + if let Some(ids) = text_frame_targets(app, d).map_err(bad)? { p.insert("ids".into(), ids); } Ok(p) } +/// Typing in a frame: the frame that showed the caret, or every frame that held part of the +/// selected text, when the dialog opened. Those ids are refused once another document is active, +/// or once Revert has replaced the document. +fn text_frame_targets(app: &DesignApp, d: &Dialog) -> Result, &'static str> { + let Some(ids) = d.fields.get("_targets").cloned() else { return Ok(typing_frame_ids(app)) }; + let active = app.session.active().map(|doc| doc.identity_epoch); + if d.fields.get("_targetsDoc").and_then(Value::as_u64) != active { + return Err("the document the dialog opened on is no longer the active one"); + } + Ok(Some(ids)) +} + /// File › Document Setup, filled from the document. /// The active document's ruler unit (picas without a document). fn doc_unit(app: &DesignApp) -> Unit { diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 272165b08..1d2bcd4a1 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1389,6 +1389,59 @@ fn text_frame_options_set_columns_baseline_and_wrap_as_indesign_does() { assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0)); } +/// The frames Text Frame Options took while typing belong to the document it opened on: when +/// another document becomes active before OK, OK changes nothing there. +#[test] +fn text_frame_options_from_the_type_tool_stay_with_their_document() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let make = |app: &mut crate::DesignApp| { + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap(); + let (id, story) = (r["id"].as_u64().unwrap(), r["story"].as_u64().unwrap()); + app.run("text.select", json!({"story": story, "anchor": 2})).unwrap(); + id + }; + let a = make(&mut app); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + assert_eq!(d.fields.get("_targets"), Some(&json!([a]))); + d.fields.insert("columns".into(), json!(3)); + // A second document, with a caret in a frame that has the same id, becomes active. + let b = make(&mut app); + assert_eq!(a, b); + app.ui.dialog = Some(d); + let err = confirm(&mut app).unwrap_err().to_string(); + assert!(err.contains("no longer the active one"), "{err}"); + let columns = app.session.active().unwrap().doc.item(designcraft_doc::ItemId(b)).and_then(|i| i.text_frame().map(|t| t.options.columns)); + assert_eq!(columns, Some(1), "the other document's frame is unchanged"); +} + +/// Revert replaces the document while Text Frame Options taken while typing stay open: OK is +/// refused, since the saved frame ids can name another frame in the reverted document. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn text_frame_options_from_the_type_tool_are_refused_after_revert() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let dir = tempfile::tempdir().unwrap(); + app.run("file.saveAs", json!({"path": dir.path().join("revert.designcraft")})).unwrap(); + let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": "Unsaved"})).unwrap(); + app.run("text.select", json!({"story": a["story"], "anchor": 1})).unwrap(); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + d.fields.insert("columns".into(), json!(3)); + app.run("file.revert", json!({})).unwrap(); + let b = app.run("frame.create", json!({"rect": [36, 200, 300, 300], "content": "text", "text": "After"})).unwrap(); + assert_eq!(a["id"], b["id"], "the reverted document gives a new frame the same id"); + app.ui.dialog = Some(d); + let err = confirm(&mut app).unwrap_err().to_string(); + assert!(err.contains("no longer the active one"), "{err}"); + assert!(app.ui.dialog.is_some(), "the dialog stays open"); + let b = designcraft_doc::ItemId(b["id"].as_u64().unwrap()); + let columns = app.session.active().unwrap().doc.item(b).and_then(|i| i.text_frame().map(|t| t.options.columns)); + assert_eq!(columns, Some(1), "the new frame is unchanged"); +} + #[test] fn baseline_grid_increment_error_explains_the_positive_measure_rule() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); From 75d1119def2c22979c4113cbbc99cb3cd02f8a99 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 10:59:49 -0800 Subject: [PATCH 5/6] Text: Text Frame Options previews go from their document, and Undo refuses stored frames A preview remembers the document it is shown in. When OK is refused or Cancel is chosen after another document became active, that document goes back as it was instead of keeping the previewed options. The frames taken from the caret or selected text are also refused once the document has changed after the dialog opened. Undo puts back the document's id counter, so a frame made after Undo can take a stored id. --- crates/ui-egui/src/dialogs.rs | 116 +++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 927ed1968..ab53948f9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -589,6 +589,68 @@ mod color_theme_tests { assert_eq!((inset(&app, a), inset(&app, b)), (24.0, 0.0)); } + /// A preview shown in one document goes when OK is refused, or Cancel is chosen, after another + /// document became active. + #[test] + fn text_frame_options_preview_goes_from_its_document_after_another_becomes_active() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let make = |app: &mut crate::DesignApp| { + app.run("file.new", json!({})).unwrap(); + let r = app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text", "text": "Hello"})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 2})).unwrap(); + designcraft_doc::ItemId(r["id"].as_u64().unwrap()) + }; + let a = make(&mut app); + let first = |app: &DesignApp| { + let st = &app.session.documents()[0]; + (st.doc.item(a).unwrap().text_frame().unwrap().options.columns, st.interaction.is_some()) + }; + let preview = |app: &mut DesignApp| { + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(app, &mut d); + d.fields.insert("columns".into(), json!("3")); + d.fields.insert("preview".into(), json!(true)); + text_frame_preview(app, &mut d); + assert_eq!(first(app), (3, true), "previewed in the first document"); + d + }; + let d = preview(&mut app); + make(&mut app); + app.ui.dialog = Some(d); + let err = confirm(&mut app).unwrap_err().to_string(); + assert!(err.contains("no longer the active one"), "{err}"); + assert_eq!(first(&app), (1, false), "OK refused: the first document is as it was"); + app.ui.dialog = None; + app.session.set_active(0); + let d = preview(&mut app); + app.session.set_active(1); + app.ui.dialog = Some(d); + cancel(&mut app); + assert_eq!(first(&app), (1, false), "Cancel: the first document is as it was"); + } + + /// Undo puts back the document's id counter, so a frame made after Undo can take the id of a + /// frame the dialog stored; OK is refused instead of changing the new frame. + #[test] + fn text_frame_options_from_the_type_tool_are_refused_after_undo_reuses_the_frame_id() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("frame.create", json!({"rect": [36, 36, 300, 120], "content": "text", "text": "First"})).unwrap(); + app.run("text.select", json!({"story": a["story"], "anchor": 1})).unwrap(); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + d.fields.insert("columns".into(), json!("3")); + app.run("edit.undo", json!({})).unwrap(); + let b = app.run("frame.create", json!({"rect": [36, 200, 300, 300], "content": "text", "text": "Second"})).unwrap(); + assert_eq!(a["id"], b["id"], "the new frame takes the undone frame's id"); + app.run("text.select", json!({"story": b["story"], "anchor": 1})).unwrap(); + app.ui.dialog = Some(d); + let err = confirm(&mut app).unwrap_err().to_string(); + assert!(err.contains("changed after the dialog opened"), "{err}"); + let b = designcraft_doc::ItemId(b["id"].as_u64().unwrap()); + assert_eq!(app.session.doc().unwrap().doc.item(b).unwrap().text_frame().unwrap().options.columns, 1, "the new frame is unchanged"); + } + #[test] fn toc_right_indent_separator_survives_the_dialog_and_update() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); @@ -1152,10 +1214,17 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { // The document state those frames are in: another document, or the document after // Revert, can reuse their ids. d.fields.insert("_targetsDoc".into(), json!(app.session.active().map(|doc| doc.identity_epoch))); + // Undo puts back the document's id counter, so a frame made after it can take a stored id. + d.fields.insert("_targetsRevision".into(), json!(app.session.active().map(base_revision))); } d.fields.insert("_shown".into(), shown); } +/// The document's revision without a preview on top: the one the open gesture began at. +fn base_revision(st: &designcraft_engine::DocState) -> u64 { + st.interaction.as_ref().map_or(st.revision, |it| it.revision) +} + /// With the Type tool and no frame selected: the frame that shows the caret, or every frame that /// holds part of the selected text. fn typing_frame_ids(app: &DesignApp) -> Option { @@ -1280,8 +1349,7 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { let shown = d.fields.get(PREVIEWED).cloned(); if !d.b("preview") { if shown.is_some() { - app.session.cancel_interaction(); - d.fields.remove(PREVIEWED); + end_text_frame_preview(app, d); d.fields.remove("status"); } return; @@ -1291,8 +1359,7 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { Err(e) => { // Values OK would refuse don't stay on the page: it goes back to how it was. if shown.is_some() { - app.session.cancel_interaction(); - d.fields.remove(PREVIEWED); + end_text_frame_preview(app, d); } d.fields.insert("status".into(), json!(e)); return; @@ -1304,16 +1371,16 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { // Nothing edited (yet, or any more): the page as it was. if params.as_object().is_some_and(|m| m.keys().all(|k| k == "ids")) { if shown.is_some() { - app.session.cancel_interaction(); - d.fields.remove(PREVIEWED); + end_text_frame_preview(app, d); } return; } - if shown.is_none() - && let Err(e) = app.session.begin_interaction("Text Frame Options") - { - d.fields.insert("status".into(), json!(e.to_string())); - return; + if shown.is_none() { + if let Err(e) = app.session.begin_interaction("Text Frame Options") { + d.fields.insert("status".into(), json!(e.to_string())); + return; + } + d.fields.insert(PREVIEW_DOC.into(), json!(app.session.active().map(|doc| doc.uid))); } match app.session.preview("object.textFrameOptions", params.clone()) { Ok(()) => d.fields.remove("status"), @@ -1322,6 +1389,15 @@ fn text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { d.fields.insert(PREVIEWED.into(), params); } +/// The preview goes from the document it was shown in, also when another document is active now. +fn end_text_frame_preview(app: &mut DesignApp, d: &mut Dialog) { + d.fields.remove(PREVIEWED); + match d.fields.remove(PREVIEW_DOC).as_ref().and_then(Value::as_u64) { + Some(uid) => app.session.cancel_interaction_of(uid), + None => app.session.cancel_interaction(), + } +} + /// Text Frame Options › General, as InDesign's: Columns (kind, number and width, gutter and /// maximum, Balance Columns), Inset Spacing (top and bottom, the link, left and right; one Inset /// for a non-rectangular frame), Vertical Justification (align, paragraph spacing limit) and @@ -1568,8 +1644,8 @@ fn text_frame_baseline_grid(d: &Dialog, bad: &dyn Fn(&str) -> String) -> Result< fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result { seed_text_frame_options(app, d); // The preview goes; the options are set once, as one undo step. - if d.fields.remove(PREVIEWED).is_some() { - app.session.cancel_interaction(); + if d.fields.contains_key(PREVIEWED) { + end_text_frame_preview(app, d); } let p = text_frame_params(app, d)?; if p.keys().all(|k| k == "ids") { @@ -1679,13 +1755,17 @@ fn text_frame_params(app: &DesignApp, d: &Dialog) -> Result, /// Typing in a frame: the frame that showed the caret, or every frame that held part of the /// selected text, when the dialog opened. Those ids are refused once another document is active, -/// or once Revert has replaced the document. +/// once Revert has replaced the document, or once the document has changed (Undo can give a new +/// frame a stored id). fn text_frame_targets(app: &DesignApp, d: &Dialog) -> Result, &'static str> { let Some(ids) = d.fields.get("_targets").cloned() else { return Ok(typing_frame_ids(app)) }; let active = app.session.active().map(|doc| doc.identity_epoch); if d.fields.get("_targetsDoc").and_then(Value::as_u64) != active { return Err("the document the dialog opened on is no longer the active one"); } + if d.fields.get("_targetsRevision").and_then(Value::as_u64) != app.session.active().map(base_revision) { + return Err("the document changed after the dialog opened"); + } Ok(Some(ids)) } @@ -4258,7 +4338,11 @@ pub fn cancel(app: &mut DesignApp) { } crate::new_document::end_preview(app); if d.as_ref().is_some_and(|d| d.fields.contains_key(PREVIEWED)) { - app.session.cancel_interaction(); + // Text Frame Options names the document its preview is in. + match d.as_ref().and_then(|d| d.fields.get(PREVIEW_DOC)).and_then(Value::as_u64) { + Some(uid) => app.session.cancel_interaction_of(uid), + None => app.session.cancel_interaction(), + } } app.cancel_pdf_import(); if let Some(d) = d @@ -8978,6 +9062,8 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { } /// The params a previewing dialog shows on the page (its interaction is open while it is set). const PREVIEWED: &str = "_previewed"; +/// The uid of the document a Text Frame Options preview is shown in. +const PREVIEW_DOC: &str = "_previewDoc"; /// Edit › Step and Repeat, as in InDesign: Repeat (Count, or Rows and Columns with Create as a /// grid), Offset (Vertical, Horizontal, in the document's ruler units) and Preview, which shows From 2aa93ea807aa7b0da2c95c342d6270ee4981c4e0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 11:10:13 -0800 Subject: [PATCH 6/6] Text: Text Frame Options from the Type tool show the inset of the frames they change The one Inset field of a non-rectangular frame and the inset link now follow the frames the dialog changes: the frame that shows the caret, or every frame holding selected text. Before, a caret with no stored frame showed four sides for an oval frame, and the link checked every frame of the story. --- crates/ui-egui/src/dialogs.rs | 47 ++++++++++++++++++----------- crates/ui-egui/src/dialogs/tests.rs | 6 ++-- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ab53948f9..7b5ff93ca 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -589,6 +589,30 @@ mod color_theme_tests { assert_eq!((inset(&app, a), inset(&app, b)), (24.0, 0.0)); } + /// With the caret in the oval second frame of a thread whose first frame has uneven insets, the + /// dialog shows the one Inset of the oval and keeps the link on: both follow the frame it changes. + #[test] + fn text_frame_options_from_the_type_tool_show_the_inset_of_the_caret_frame() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let text = (0..40).map(|i| format!("Line {i} of the threaded story.")).collect::>().join(" "); + let 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], "shape": "ellipse", "content": "text", "text": "", "caret": false})).unwrap(); + let (a, b, sid) = + (designcraft_doc::ItemId(a["id"].as_u64().unwrap()), designcraft_doc::ItemId(b["id"].as_u64().unwrap()), a["story"].as_u64().unwrap()); + app.run("text.thread", json!({"from": a.0, "to": b.0})).unwrap(); + app.run("object.textFrameOptions", json!({"ids": [a.0], "inset": [1, 2, 3, 4]})).unwrap(); + let cs = designcraft_compose::compose_story(&app.session.doc().unwrap().doc, designcraft_doc::StoryId(sid), &Default::default()); + let caret = cs.frame(b).unwrap().range.start + 2; + app.run("text.select", json!({"story": sid, "anchor": caret})).unwrap(); + let mut d = Dialog::new("textFrameOptions", json!({})); + seed_text_frame_options(&app, &mut d); + assert_eq!(d.fields.get("_targets"), Some(&json!([b.0]))); + assert!(d.b("_shaped"), "the oval shows one Inset"); + assert!(d.b("insetLinked"), "the first frame's uneven sides do not turn the link off"); + } + /// A preview shown in one document goes when OK is refused, or Cancel is chosen, after another /// document became active. #[test] @@ -1105,25 +1129,17 @@ fn selected_frame_options(app: &DesignApp) -> Option bool { let Some(st) = app.session.active() else { return true; }; - let mut ids = st.selection.items.clone(); - if ids.is_empty() - && let Some(t) = st.selection.text - { - ids = match t.frame { - Some(f) => vec![f], - None => st.doc.story(t.story).map(|s| s.frames.clone()).unwrap_or_default(), - }; - } + let ids = designcraft_engine::cmd::text_frame_options_frames(&app.session); ids.iter().filter_map(|id| st.doc.item(*id)?.text_frame()).all(|t| t.options.inset.iter().all(|v| (v - t.options.inset[0]).abs() < 1e-9)) } -/// Whether every selected text frame (the selected items, or the frame of the caret) has an -/// outline that is not its own axis-aligned rectangle: an oval, a polygon, rounded or other corner +/// Whether every text frame the dialog changes (the selected items, else the frame that shows the +/// caret or every frame holding selected text) has an outline that is not its own axis-aligned rectangle: an oval, a polygon, rounded or other corner /// options. Its text keeps one inset from the outline, as compose sets it; type on a path doesn't. /// A selection that mixes them with rectangular frames shows the four sides, so each frame keeps /// the sides left as shown. @@ -1137,11 +1153,8 @@ fn selected_frame_is_shaped(app: &DesignApp) -> bool { let bp = if item.corners.is_none() { item.path.to_bezpath() } else { designcraft_geom::corners::apply(&item.path, &item.corners) }; Some(designcraft_compose::FrameOutline::new(&bp, 0.0).is_some()) }; - if st.selection.items.is_empty() { - let Some(fid) = st.selection.text.and_then(|t| t.frame) else { return false }; - return shaped(fid).unwrap_or(false); - } - st.selection.items.iter().all(|&fid| shaped(fid).unwrap_or(false)) + let ids = designcraft_engine::cmd::text_frame_options_frames(&app.session); + !ids.is_empty() && ids.iter().all(|&fid| shaped(fid).unwrap_or(false)) } /// Object › Text Frame Options shows the selected frame's options. `_shown` keeps what it showed, diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 9f66fb4f8..0bad3ce0e 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -1831,15 +1831,15 @@ fn text_frame_options_link_the_insets_only_when_every_selected_frame_has_even_si assert!(!seeded(&app).b("insetLinked")); app.run("text.select", json!({"story": other_even_story, "anchor": 0, "focus": 0})).unwrap(); assert!(seeded(&app).b("insetLinked")); - // A story threaded on into the uneven frame: a caret placed in the first frame changes only - // that frame, and a caret with no frame changes every frame of the story. + // A story threaded on into the uneven frame: a caret placed in the first frame, or a caret + // with no stored frame drawn in the first frame, changes only that frame. let (first, story) = frame(&mut app, [300, 300, 500, 500], json!(6)); let (last, _) = frame(&mut app, [300, 520, 500, 700], json!([1, 2, 3, 4])); app.run("text.thread", json!({"from": first.0, "to": last.0})).unwrap(); app.run("text.placeCaret", json!({"frame": first.0, "point": [310, 310]})).unwrap(); assert!(seeded(&app).b("insetLinked")); app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap(); - assert!(!seeded(&app).b("insetLinked")); + assert!(seeded(&app).b("insetLinked")); } #[test]