From 37e891544d062a509457d1af4e65f00a76f67351 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 22:55:13 +0100 Subject: [PATCH] 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 --- .../engine/src/cmd/draw/tests/page_lookup.rs | 1 + crates/layout/src/display/page.rs | 1 + crates/layout/src/floats.rs | 29 ++++-- crates/layout/src/hit.rs | 74 ++++++++++++++- crates/layout/src/placed.rs | 14 ++- crates/layout/src/tests.rs | 92 +++++++++++++++++++ crates/ui-egui/src/canvas/story.rs | 2 +- crates/ui-egui/src/objects/tests.rs | 83 +++++++++++++++++ 8 files changed, 279 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/draw/tests/page_lookup.rs b/crates/engine/src/cmd/draw/tests/page_lookup.rs index b1a6623afa..4b225cf903 100644 --- a/crates/engine/src/cmd/draw/tests/page_lookup.rs +++ b/crates/engine/src/cmd/draw/tests/page_lookup.rs @@ -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(), } } diff --git a/crates/layout/src/display/page.rs b/crates/layout/src/display/page.rs index 1dc4f2d50e..e69f9ec453 100644 --- a/crates/layout/src/display/page.rs +++ b/crates/layout/src/display/page.rs @@ -611,6 +611,7 @@ mod tests_warp_lookups { text_box: id, wrap: Default::default(), origin: Default::default(), + group_boxes: Vec::new(), } } diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index 25f00370f0..933c09c321 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -778,16 +778,6 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec (Vec 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 }; diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index a7ae6aae5a..3469aff95c 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -142,7 +142,7 @@ fn all_objects(page: &Page, index: usize) -> impl Iterator + ' fn objects_in(items: &[Placed], index: usize) -> impl Iterator + '_ { 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 }) } +/// 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> + '_ { + 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 { let boxes: Vec = 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 { 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 { + 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 { @@ -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![ diff --git a/crates/layout/src/placed.rs b/crates/layout/src/placed.rs index cc9ab49cbb..e93c934543 100644 --- a/crates/layout/src/placed.rs +++ b/crates/layout/src/placed.rs @@ -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); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 6db3eaa84c..cde16782c5 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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); diff --git a/crates/ui-egui/src/canvas/story.rs b/crates/ui-egui/src/canvas/story.rs index 366faeaf10..e8ee78a17e 100644 --- a/crates/ui-egui/src/canvas/story.rs +++ b/crates/ui-egui/src/canvas/story.rs @@ -15,7 +15,7 @@ pub(crate) fn part_kind(app: &WordApp, s: StoryRef) -> Option { /// 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 { 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, } } diff --git a/crates/ui-egui/src/objects/tests.rs b/crates/ui-egui/src/objects/tests.rs index 4d6ee34cbf..3f1cad5980 100644 --- a/crates/ui-egui/src/objects/tests.rs +++ b/crates/ui-egui/src/objects/tests.rs @@ -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