Thread the loaded text cursor into a text frame inside a group (gaps 27)

A click with the loaded text cursor on a text frame inside a group showed the
not-allowed cursor and did nothing: `Document::hit_item` walks the spread's
top-level items, so the hit was the group, and the group is not a text frame.
`item_hit` already looks inside a group, but only to answer whether the group
was hit at all -- the frame in it was never named. InDesign threads into it.

`hit_descendant` names it: the frontmost descendant under a point that a
predicate accepts, through nested groups, front to back and skipping hidden
children. `ToolContext::hit_text_frame` asks for a text frame, and the thread
tool resolves its target through it, falling back to the plain hit so a click on
anything else means what it meant before. `hit` is untouched, so selection still
picks the group.

One fix covers the cursor and the click, since `click` resolves through
`target`. The test asserts the cursor, that the click threads to the frame and
not to the group, that `hit` still returns the group, and that a group holding
nothing threadable is still refused.
This commit is contained in:
Hamza committed 2026-10-16 00:27:53 +05:00
1 parent a930276fd7
commit 39ef7799c0
5 files changed
+91 -3

No files matched your search

+24
View File
@@ -587,6 +587,30 @@ pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
designcraft_geom::hit::stroke_contains(&bp, it.stroke.weight.max(1.0), tol + reach, inner)
}
/// The frontmost descendant of `it` under `p` that `want` accepts, looking inside nested groups;
/// `it` itself when nothing inside it matches and `want` accepts it. `p` is in the space `it`
/// sits in, as [`item_hit`] takes it.
///
/// Children are tried front to back and a hidden one is skipped, so of several that `want`
/// accepts the answer is the one in front. Objects `want` rejects do not hide the ones behind
/// them: a text frame under a rectangle in the same group is still found.
pub fn hit_descendant(it: &Item, p: Point, tol: f64, want: &dyn Fn(&Item) -> bool) -> Option<ItemId> {
if !item_hit(it, p, tol) {
return None;
}
let inner = it.xf.inverse() * p;
let children: Vec<&Arc<Item>> = it.shown_children().collect();
for c in children.into_iter().rev() {
if c.hidden {
continue;
}
if let Some(id) = hit_descendant(c, inner, tol, want) {
return Some(id);
}
}
want(it).then_some(it.id)
}
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
/// either side (the type may sit on or under the path).
const PATH_TYPE_REACH: f64 = 12.0;
+1 -1
View File
@@ -46,7 +46,7 @@ pub use datamerge::{
};
pub use designcraft_color as color;
pub use designcraft_geom as geom;
pub use edit::{ItemLoc, ItemPath, SpreadRef, item_hit as edit_hit};
pub use edit::{ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit};
pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK};
pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize};
pub use ids::*;
+13
View File
@@ -232,6 +232,19 @@ impl ToolContext<'_> {
self.doc.hit_item(si, sp, self.tol(4.0)).map(|id| (sr, id))
}
/// The frontmost text frame under `p`, looking inside groups. [`Self::hit`] stops at the
/// group, so a loaded text cursor over a text frame in a group found nothing to thread into
/// (gaps 27); InDesign threads into it.
pub fn hit_text_frame(&self, p: Point) -> Option<(SpreadRef, ItemId)> {
let (sr, id) = self.hit(p)?;
let it = self.doc.item(id)?;
if it.is_text_frame() {
return Some((sr, id));
}
let (_, sp) = self.layout.spread_at(p)?;
designcraft_doc::hit_descendant(it, sp, self.tol(4.0), &|c| c.is_text_frame()).map(|f| (sr, f))
}
/// The parent item showing (not yet overridden) on the document page under `p`:
/// (absolute page, parent item).
pub fn hit_parent_on_page(&self, p: Point) -> Option<(usize, ItemId)> {
+51 -1
View File
@@ -1252,7 +1252,8 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() {
// Children [vector, raster] with the raster on top: it samples only while it is the active state.
for (active, samples) in [(1, true), (0, false)] {
let mut group = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area));
group.content = Content::Group { items: vec![std::sync::Arc::new(vector.clone()), std::sync::Arc::new(raster.clone())] };
group.content =
designcraft_doc::item::Content::Group { items: vec![std::sync::Arc::new(vector.clone()), std::sync::Arc::new(raster.clone())] };
group.states = vec!["a".into(), "b".into()];
group.active_state = active;
let mut d2 = d.clone();
@@ -1314,3 +1315,52 @@ fn anchor_tools_show_pen_state_cursors() {
assert_eq!(cursor("deleteAnchor"), Cursor::PenDelete);
assert_eq!(cursor("convertDirection"), Cursor::PenConvert);
}
#[test]
fn loaded_text_cursor_threads_into_a_frame_inside_a_group() {
// `hit` stops at the group, so the loaded cursor showed NotAllowed over a text frame inside
// one and a click did nothing. InDesign threads into it (gaps 27).
let mut td = thread_doc();
let lid = td.d.default_layer();
let rect = Rect::new(600.0, 100.0, 700.0, 200.0);
// The frame, then the same frame moved inside a group. Both transforms are the identity, so
// the child's coordinates stay the spread's.
let (inner, _) = td.d.add_text_frame(SpreadRef::Doc(0), rect, lid, "", ParaFormat::default()).unwrap();
let frame_item = td.d.item(inner).cloned().unwrap();
td.d.remove_item(inner).unwrap();
let gid = ItemId(td.d.alloc());
let mut group = Item::new(gid, lid, Shape::Group, shapes::rectangle(rect));
group.content = designcraft_doc::item::Content::Group { items: vec![std::sync::Arc::new(frame_item)] };
td.d.insert_item(SpreadRef::Doc(0), group, None).unwrap();
td.l = CanvasLayout::new(&td.d, false);
let mut t = create("selection");
let cx = td.cx();
click(&mut t, &cx, td.port(td.a, true), Mods::default());
let p = td.at(650.0, 150.0);
// Selection still reaches the group, which is what a plain click there should pick.
assert_eq!(cx.hit(p).map(|(_, i)| i), Some(gid));
// The loaded text cursor reaches the frame inside it.
assert_eq!(cx.hit_text_frame(p).map(|(_, i)| i), Some(inner), "the frame in the group");
assert_eq!(t.cursor(&cx, p, Mods::default()), Cursor::ThreadLink);
// And the click threads to that frame, not to the group.
assert_eq!(click(&mut t, &cx, p, Mods::default()), thread_exec(serde_json::json!({"from": td.a.0, "to": inner.0})));
// A group with nothing threadable in it still says no.
let mut td2 = thread_doc();
let r2 = Rect::new(600.0, 300.0, 700.0, 400.0);
let plain = ItemId(td2.d.alloc());
let child = Item::new(plain, lid, Shape::Rectangle, shapes::rectangle(r2));
let g2 = ItemId(td2.d.alloc());
let mut group2 = Item::new(g2, lid, Shape::Group, shapes::rectangle(r2));
group2.content = designcraft_doc::item::Content::Group { items: vec![std::sync::Arc::new(child)] };
td2.d.insert_item(SpreadRef::Doc(0), group2, None).unwrap();
td2.l = CanvasLayout::new(&td2.d, false);
let cx2 = td2.cx();
let mut t2 = create("selection");
click(&mut t2, &cx2, td2.port(td2.a, true), Mods::default());
let q = td2.at(650.0, 350.0);
assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group");
assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed);
}
+2 -1
View File
@@ -194,7 +194,8 @@ impl Loaded {
}
pub fn target(self, cx: &ToolContext, p: Point) -> Target {
let Some((_, id)) = cx.hit(p) else { return Target::Empty };
// A text frame inside a group is the frame the click means, not the group (gaps 27).
let Some((_, id)) = cx.hit_text_frame(p).or_else(|| cx.hit(p)) else { return Target::Empty };
if Some(id) == self.neighbour(cx.doc) {
return Target::Unthread;
}