Merge pull request #6051 from vangel76/fix/6027-textbox-edit

Text boxes inside a group can be edited
This commit is contained in:
FredApps authored and GitHub committed 2026-11-13 02:17:07 +01:00
commit 12ce548607
8 files changed
+313 -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 };
+74 -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,24 @@ 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 (a group's last member is painted on top).
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()
.rev()
.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 +517,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 +544,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 +573,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 +1087,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);
+122
View File
@@ -3902,6 +3902,127 @@ 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");
}
/// #6027: where two text boxes of one group overlap, a click enters the one painted on top (the
/// last member), in the body and in a header alike.
#[test]
fn click_in_overlapping_grouped_text_boxes_enters_the_top_one() {
let (low, top) = (3, 4);
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, 0.0, 100.0, 100.0), low), (wordcraft_geom::Rect::new(50.0, 50.0, 100.0, 100.0), top)],
};
let page = Page {
items: vec![group(wordcraft_geom::Rect::new(100.0, 100.0, 150.0, 150.0))],
header: vec![group(wordcraft_geom::Rect::new(100.0, 100.0, 150.0, 150.0))],
..Default::default()
};
let l = DocLayout { pages: vec![page], ..Default::default() };
// Overlap (page 175, 175 is inside both boxes): the top box takes the click, and typing.
assert_eq!(l.story_at(0, 175.0, 175.0), Some(StoryRef::Part(top)));
assert_eq!(l.header_footer_text_box_at(0, 175.0, 175.0), Some(top));
// Outside the overlap each box still answers for itself.
assert_eq!(l.story_at(0, 110.0, 110.0), Some(StoryRef::Part(low)));
assert_eq!(l.story_at(0, 240.0, 240.0), Some(StoryRef::Part(top)));
}
#[test]
fn deleted_float_leaves_no_wrap_area_without_markup() {
crate::test_fonts();
@@ -12451,6 +12572,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