Text boxes in a group: a click edits their text

A text box inside a group (Word's cards made of a coloured bar and a text
box) could not be edited: every click selected the whole group, and typing
then replaced the group with the typed text. Layout now keeps each group's
text box areas on the group's object, and hit testing treats them like a
text box on its own: inside is the box's text, the border and the group's
other members still grab the group (#6027).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-11-08 23:04:59 +01:00
1 parent 4b0d399599
commit 37e891544d
8 files changed
+279 -17

No files matched your search

@@ -33,6 +33,7 @@ fn placed(story: StoryRef, path: Path, off: usize, x: f32) -> Placed {
text_box: None,
wrap: Wrap::InFrontOfText,
origin: Default::default(),
group_boxes: Vec::new(),
}
}
+1
View File
@@ -611,6 +611,7 @@ mod tests_warp_lookups {
text_box: id,
wrap: Default::default(),
origin: Default::default(),
group_boxes: Vec::new(),
}
}
+19 -10
View File
@@ -778,16 +778,6 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
};
// A box linked from another shows (and edits) that box's story.
let shown = text_box.map(|id| textbox::shown_story(ctx, id));
front.push(Placed::Object {
rect,
spin: float.spin(),
story,
path: Path(path.to_vec()),
off: c.start,
text_box: shown,
wrap: float.wrap,
origin: Point::new(at.col.0, late_here.map_or(at.para_y, |l| l.top)),
});
// Its text (a group: its text boxes', each where the group's turn carries it; the
// text itself stays upright), unless the box budget says no (a box inside itself,
// too deep, too many).
@@ -803,6 +793,25 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
.collect(),
_ => text_box.map(|id| (id, rect)).into_iter().collect(),
};
// A group's text boxes are where a click edits their text (#6027): kept on the
// group's area, relative to it so they move with it.
let group_boxes = match obj {
InlineObject::Group { .. } => {
boxes.iter().map(|&(id, r)| (Rect::new(r.x - rect.x, r.y - rect.y, r.w, r.h), textbox::shown_story(ctx, id))).collect()
}
_ => Vec::new(),
};
front.push(Placed::Object {
rect,
spin: float.spin(),
story,
path: Path(path.to_vec()),
off: c.start,
text_box: shown,
wrap: float.wrap,
origin: Point::new(at.col.0, late_here.map_or(at.para_y, |l| l.top)),
group_boxes,
});
for (id, rect) in boxes {
let items = textbox::box_items(ctx, id, rect, depth);
let layer = if float.wrap == Wrap::BehindText { &mut behind } else { &mut front };
+70 -4
View File
@@ -142,7 +142,7 @@ fn all_objects(page: &Page, index: usize) -> impl Iterator<Item = ObjectHit> + '
fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit> + '_ {
items.iter().rev().filter_map(move |it| match it {
Placed::Object { rect, spin, story, path, off, text_box, wrap, origin } => Some(ObjectHit {
Placed::Object { rect, spin, story, path, off, text_box, wrap, origin, .. } => Some(ObjectHit {
page: index,
rect: *rect,
spin: *spin,
@@ -158,6 +158,35 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit>
})
}
/// A text box inside a group, on the page: the group's place (`story`, `path`, `off`), the box's
/// upright page frame and the story it shows.
struct GroupBox<'a> {
story: StoryRef,
path: &'a Path,
off: usize,
rect: Rect,
part: u32,
}
/// The text boxes of the groups among `items`, topmost first (as [`objects_in`]).
fn group_boxes_in(items: &[Placed]) -> impl Iterator<Item = GroupBox<'_>> + '_ {
items.iter().rev().flat_map(|it| {
let boxes = match it {
Placed::Object { rect, story, path, off, group_boxes, .. } => Some((rect, story, path, off, group_boxes)),
_ => None,
};
boxes.into_iter().flat_map(|(rect, story, path, off, group_boxes)| {
group_boxes.iter().rev().map(move |(r, part)| GroupBox {
story: *story,
path,
off: *off,
rect: Rect::new(rect.x + r.x, rect.y + r.y, r.w, r.h),
part: *part,
})
})
})
}
/// The story of the text (not a text box's) near (x, y): body, footnotes, endnotes.
fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option<StoryRef> {
let boxes: Vec<StoryRef> = objects(p, index).filter_map(|o| o.text_box.map(StoryRef::Part)).collect();
@@ -437,8 +466,20 @@ impl DocLayout {
/// body text is.
pub fn story_at(&self, page: usize, x: f32, y: f32) -> Option<StoryRef> {
let p = self.pages.get(page)?;
// A group's text boxes count as text boxes too (#6027).
// Topmost first, a text box on its own or one in a group alike.
let text_box = |behind: bool| {
objects(p, page).find(|o| o.behind() == behind && o.text_box.is_some() && o.rect.contains(o.unspin(x, y))).and_then(|o| o.text_box)
p.items.iter().rev().find_map(|it| {
let Placed::Object { rect, spin, text_box, wrap, group_boxes, .. } = it else { return None };
if (*wrap == Wrap::BehindText) != behind {
return None;
}
let centre = Point::new(rect.x + rect.w / 2.0, rect.y + rect.h / 2.0);
let (ux, uy) = spin.unapply(centre.x, centre.y, x, y);
text_box.filter(|_| rect.contains(Point::new(ux, uy))).or_else(|| {
group_boxes.iter().find(|(r, _)| Rect::new(rect.x + r.x, rect.y + r.y, r.w, r.h).contains(Point::new(x, y))).map(|(_, id)| *id)
})
})
};
text_box(false).map(StoryRef::Part).or_else(|| text_at(p, page, x, y)).or_else(|| text_box(true).map(StoryRef::Part))
}
@@ -472,12 +513,23 @@ impl DocLayout {
// (a form scanned as a picture, with text boxes laid over it to fill in). The same holds
// among the objects behind the text.
let inside_text_box = |o: &ObjectHit| o.text_box.is_some() && !grabs(o) && o.rect.contains(o.unspin(x, y));
// Inside one of a group's text boxes (off its border) is that box's text too (#6027).
let in_group_box = |o: &ObjectHit| {
let items: [&[Placed]; 3] = [&p.items, &p.header, &p.footer];
items.into_iter().flat_map(group_boxes_in).any(|b| {
b.story == o.story
&& *b.path == o.path
&& b.off == o.off
&& b.rect.w.min(b.rect.h) > edge * 3.0
&& b.rect.expand(-edge).contains(Point::new(x, y))
})
};
let topmost = |behind: bool| cands.iter().find(|o| o.behind() == behind && (grabs(o) || inside_text_box(o)));
match topmost(false) {
Some(o) if inside_text_box(o) => None,
Some(o) if inside_text_box(o) || in_group_box(o) => None,
Some(o) => Some(o.clone()),
None if text_here => None,
None => topmost(true).filter(|o| !inside_text_box(o)).cloned(),
None => topmost(true).filter(|o| !inside_text_box(o) && !in_group_box(o)).cloned(),
}
}
@@ -488,6 +540,7 @@ impl DocLayout {
.chain(objects_in(&p.footer, page))
.find(|o| o.text_box.is_some() && o.rect.contains(Point::new(x, y)))
.and_then(|o| o.text_box)
.or_else(|| group_boxes_in(&p.header).chain(group_boxes_in(&p.footer)).find(|b| b.rect.contains(Point::new(x, y))).map(|b| b.part))
}
/// Where the object at `pos` (its U+FFFC) is laid out.
@@ -516,6 +569,18 @@ impl DocLayout {
self.find_object(page_hint, |o| o.text_box == Some(part))
}
/// The story the text box showing `part` sits in (where the box or its group is).
pub fn text_box_story(&self, part: u32, page_hint: usize) -> Option<StoryRef> {
self.text_box(part, page_hint).map(|o| o.story).or_else(|| {
let on = |i: usize| {
let p = self.pages.get(i)?;
let items: [&[Placed]; 3] = [&p.items, &p.header, &p.footer];
items.into_iter().flat_map(group_boxes_in).find(|b| b.part == part).map(|b| b.story)
};
on(page_hint).or_else(|| (0..self.pages.len()).filter(|i| *i != page_hint).find_map(on))
})
}
/// The first object matching `f`, looking on `page_hint` first (where the caret is: one page
/// to scan, not the whole document).
pub fn find_object(&self, page_hint: usize, f: impl Fn(&ObjectHit) -> bool) -> Option<ObjectHit> {
@@ -1018,6 +1083,7 @@ mod object_index_tests {
text_box: None,
wrap: Wrap::InFrontOfText,
origin: Point::new(5.0, 6.0),
group_boxes: Vec::new(),
};
let layout = DocLayout {
pages: vec![
+12 -2
View File
@@ -105,6 +105,10 @@ pub enum Placed {
/// Its paragraph's column left and top: where offsets relative to the column and the
/// paragraph (`Anchor::Column` / `Anchor::Paragraph`) start.
origin: wordcraft_geom::Point,
/// A group's text boxes: each one's upright frame relative to `rect`'s top-left (so it
/// moves with the group; [`Placed::turn`] turns it with the group) and the story it shows.
/// Empty for anything else.
group_boxes: Vec<(Rect, u32)>,
},
}
@@ -179,9 +183,15 @@ impl Placed {
| Placed::Cell { rect, .. } => {
*rect = turn_rect(turn, x, y, *rect);
}
Placed::Object { rect, origin, .. } => {
*rect = turn_rect(turn, x, y, *rect);
Placed::Object { rect, origin, group_boxes, .. } => {
let old = *rect;
*rect = turn_rect(turn, x, y, old);
(origin.x, origin.y) = turn_point(turn, x, y, origin.x, origin.y);
// A group's text boxes turn with it, kept relative to its new frame.
for (r, _) in group_boxes.iter_mut() {
let t = turn_rect(turn, x, y, Rect::new(old.x + r.x, old.y + r.y, r.w, r.h));
*r = Rect::new(t.x - rect.x, t.y - rect.y, t.w, t.h);
}
}
Placed::Clip { rect, items, .. } => {
*rect = turn_rect(turn, x, y, *rect);
+92
View File
@@ -3901,6 +3901,97 @@ fn hidden_drawings_are_neither_drawn_nor_take_room() {
assert_eq!(draws.iter().filter(|x| matches!(x, display::Draw::Shape { .. })).count(), 1, "{draws:?}");
}
/// #6027: a text box inside a group (Word's cards of a coloured bar and a text box) is text to
/// a click, as a text box on its own is: the click edits its text instead of grabbing the
/// group. The group's other members still grab the group.
#[test]
fn a_grouped_text_box_is_text_to_a_click() {
crate::test_fonts();
let mut d = Document::from_text("body text");
let id = d.add_part(
wordcraft_doc::PartKind::TextBox,
vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("inside", Default::default()))],
);
let shape = |kind, w, h, story| InlineObject::Shape {
kind,
w,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Default::default(),
story,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::InFrontOfText,
h_rel: wordcraft_doc::para::Anchor::Page,
v_rel: wordcraft_doc::para::Anchor::Page,
x: 100.0,
y: 300.0,
..Default::default()
};
let group = InlineObject::Group {
w: 200.0,
h: 100.0,
float,
ch_w: 200.0,
ch_h: 100.0,
children: vec![
wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: shape(wordcraft_doc::para::ShapeKind::Rectangle, 200.0, 30.0, None) },
wordcraft_doc::para::GroupChild { x: 0.0, y: 40.0, obj: shape(wordcraft_doc::para::ShapeKind::TextBox, 200.0, 60.0, Some(id)) },
],
canvas: None,
};
d.insert_object(&Pos::body(0, 0), group, &Default::default()).unwrap();
let l = lay(&d);
// Inside the text box: its story, and no object to grab.
assert_eq!(l.story_at(0, 200.0, 370.0), Some(StoryRef::Part(id)));
assert_eq!(l.object_at(0, 200.0, 370.0, 4.0), None);
assert_eq!(l.text_box_story(id, 0), Some(StoryRef::Body), "the box's text sits in the body");
// On the bar, and on the text box's border: the group.
for (x, y) in [(200.0, 315.0), (200.0, 340.5)] {
let hit = l.object_at(0, x, y, 4.0).unwrap_or_else(|| panic!("the group at {x}, {y}"));
assert_eq!((hit.path.clone(), hit.text_box), (Path::top(0), None));
}
assert_ne!(l.story_at(0, 200.0, 315.0), Some(StoryRef::Part(id)));
// The group keeps its text box's area relative to itself, so the area moves with it.
let Some(Placed::Object { rect, group_boxes, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Object { .. })) else { panic!() };
assert_eq!((rect.x, rect.y), (100.0, 300.0));
assert_eq!(group_boxes.as_slice(), [(wordcraft_geom::Rect::new(0.0, 40.0, 200.0, 60.0), id)]);
}
/// #6027: a group's text box areas turn with the group (a group in a cell or text box whose
/// text runs down the page) and are found in headers and footers too.
#[test]
fn grouped_text_box_areas_turn_with_the_group_and_work_in_headers() {
let id = 7;
let group = |rect| Placed::Object {
rect,
spin: Default::default(),
story: StoryRef::Part(1),
path: Path::top(0),
off: 0,
text_box: None,
wrap: Default::default(),
origin: Default::default(),
group_boxes: vec![(wordcraft_geom::Rect::new(0.0, 40.0, 200.0, 60.0), id)],
};
let mut g = group(wordcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0));
g.turn(TextDirection::Down, 300.0, 50.0);
let Placed::Object { rect, group_boxes, .. } = &g else { panic!() };
let want = crate::placed::turn_rect(TextDirection::Down, 300.0, 50.0, wordcraft_geom::Rect::new(0.0, 40.0, 200.0, 60.0));
let (r, _) = group_boxes[0];
assert_eq!(wordcraft_geom::Rect::new(rect.x + r.x, rect.y + r.y, r.w, r.h), want);
// A header's group: its box is where a click in the header edits text.
let page = Page { header: vec![group(wordcraft_geom::Rect::new(100.0, 30.0, 200.0, 100.0))], ..Default::default() };
let l = DocLayout { pages: vec![page], ..Default::default() };
assert_eq!(l.header_footer_text_box_at(0, 150.0, 100.0), Some(id));
assert_eq!(l.header_footer_text_box_at(0, 150.0, 40.0), None, "the group's bar is not text");
}
#[test]
fn deleted_float_leaves_no_wrap_area_without_markup() {
crate::test_fonts();
@@ -12380,6 +12471,7 @@ fn split_cell_moves_image_frame() {
text_box: None,
wrap: Wrap::Square,
origin: wordcraft_geom::Point::new(3.0, 145.0),
group_boxes: Vec::new(),
};
let mut kept = image.clone();
kept.translate(0.0, top - 150.0);
+1 -1
View File
@@ -15,7 +15,7 @@ pub(crate) fn part_kind(app: &WordApp, s: StoryRef) -> Option<PartKind> {
/// The story a text box's story sits in (where the box is), when the caret is in a text box.
pub(crate) fn text_box_host(app: &WordApp, layout: &DocLayout) -> Option<StoryRef> {
match app.session.sel.focus.story {
StoryRef::Part(id) if in_text_box(app) => layout.text_box(id, app.session.page_hint).map(|o| o.story),
StoryRef::Part(id) if in_text_box(app) => layout.text_box_story(id, app.session.page_hint),
_ => None,
}
}
+83
View File
@@ -1018,6 +1018,89 @@ fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
}
/// #6027: a click, or a double-click, in the text of a text box inside a group puts the caret
/// there, and typing edits that text; the group stays. Before, the click selected the whole
/// group and typing replaced it.
#[test]
fn typing_after_a_click_in_a_grouped_text_box_edits_its_text() {
use egui::{Event, PointerButton};
for clicks in [1, 2] {
let mut d = Document::new();
let id = d.add_part(
wordcraft_doc::PartKind::TextBox,
vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Card", Default::default()))],
);
let shape = |kind, h, story| InlineObject::Shape {
kind,
w: 200.0,
h,
fill: None,
stroke: None,
stroke_width: 1.0,
float: Default::default(),
story,
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let float = wordcraft_doc::para::Float {
wrap: wordcraft_doc::para::Wrap::InFrontOfText,
h_rel: wordcraft_doc::para::Anchor::Page,
v_rel: wordcraft_doc::para::Anchor::Page,
x: 150.0,
y: 200.0,
..Default::default()
};
let group = InlineObject::Group {
w: 200.0,
h: 100.0,
float,
ch_w: 200.0,
ch_h: 100.0,
children: vec![
wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj: shape(ShapeKind::Rectangle, 30.0, None) },
wordcraft_doc::para::GroupChild { x: 0.0, y: 40.0, obj: shape(ShapeKind::TextBox, 60.0, Some(id)) },
],
canvas: None,
};
d.insert_object(&Pos::body(0, 0), group, &Default::default()).unwrap();
let mut a = WordApp::new(wordcraft_engine::Session::new(d), crate::Services::default());
a.run("view.commentsPane", json!({"value": false})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
// In the box's text, right of "Card" (the box starts 40 pt into the group).
let at = crate::canvas::page_to_screen(h.state_mut(), 0, 150.0 + 120.0, 200.0 + 52.0).unwrap();
h.event(Event::PointerMoved(at));
for _ in 0..clicks {
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() });
}
for _ in 0..3 {
h.step();
}
h.event(Event::Text("!".into()));
for _ in 0..3 {
h.step();
}
let doc = &h.state().session.doc;
let text: String = doc.parts.get(&id).unwrap().blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect();
assert!(text.contains('!'), "{clicks} click(s): typed into the box: {text:?}");
assert!(
matches!(doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()), Some(InlineObject::Group { .. })),
"{clicks} click(s): the group is still there"
);
}
}
/// #2637: inside a Drawing Canvas the mouse works on the members, as in Word. A click on a
/// shape picks that shape (the canvas stays the selection in the text) and shows its frame;
/// dragging it moves it inside the canvas; a click in a text box member's text puts the