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