From d51f011a554e03b3143ae50938f9d3fbdba959ce Mon Sep 17 00:00:00 2001 From: Dhruv Date: Sun, 18 Oct 2026 11:45:51 -0600 Subject: [PATCH] Cross-references are links: Ctrl+click goes to the target (#2945) --- crates/ui-egui/src/canvas.rs | 69 +++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fabe1d43d..d6affa863 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3340,12 +3340,21 @@ fn link_under(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, po let at = wordcraft_geom::Point::new(x, y); // The caret falls between characters: the one under the point is on either side of it. [(pos.off, para.next_boundary(pos.off)), (para.prev_boundary(pos.off), pos.off)].into_iter().filter(|(a, b)| a < b).find_map(|(a, b)| { - let link = para.props_of_char(a).link.clone().filter(|l| !l.is_empty())?; + let link = para.props_of_char(a).link.clone().filter(|l| !l.is_empty()).or_else(|| field_link(para.object_at(a)?))?; let (from, to) = (Pos { off: a, ..pos.clone() }, Pos { off: b, ..pos.clone() }); layout.selection_rects(doc, &from, &to, page).into_iter().any(|(pi, r)| pi == page && r.contains(at)).then_some(link) }) } +/// The link a cross-reference field makes: `REF name \h` and `PAGEREF name \h` lead to the +/// bookmark `name` (`#name`), as in Word; without `\h` the result is plain text (#2945). +fn field_link(obj: &wordcraft_doc::InlineObject) -> Option { + let wordcraft_doc::InlineObject::Field { instr, .. } = obj else { return None }; + let code = wordcraft_engine::cmd::references::FieldCode::parse(instr); + let name = code.args.first().filter(|n| !n.is_empty())?; + (matches!(code.name.as_str(), "REF" | "PAGEREF") && code.has('h')).then(|| format!("#{name}")) +} + /// A link to a hidden bookmark (`#_Toc…`, `#_Ref…`): a name the program made, which nobody /// chose or sees, so the hover tip says where it goes instead of showing it (#2177). fn is_generated_bookmark_link(link: &str) -> bool { @@ -4981,6 +4990,64 @@ mod tests { assert_eq!(s.sel.focus, start, "at the bookmark's start"); } + /// #2945: only a `REF` or `PAGEREF` with `\h` and a bookmark makes a link. + #[test] + fn only_hyperlinked_reference_fields_make_links() { + let field = |instr: &str| wordcraft_doc::InlineObject::Field { + instr: instr.into(), + result: "x".into(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }; + assert_eq!(field_link(&field(r"REF _Ref000000001 \h")).as_deref(), Some("#_Ref000000001")); + assert_eq!(field_link(&field(r"PAGEREF Spot \h")).as_deref(), Some("#Spot")); + assert_eq!(field_link(&field(r"ref Spot \* MERGEFORMAT \H")).as_deref(), Some("#Spot")); + for plain in [r"REF Spot", r"REF \h", "REF", "", r"PAGE \h", r"TOC \h", r"SEQ Figure \h"] { + assert_eq!(field_link(&field(plain)), None, "{plain:?}"); + } + } + + /// #2945: a cross-reference (`REF … \h`) is a link to its target: Ctrl+click on it goes there, + /// and its hover tip names no bookmark. + #[test] + fn ctrl_click_on_a_cross_reference_goes_to_its_target() { + let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Rock\nSee ")), Default::default()); + a.run("select.text", json!({"text": "Rock"})).unwrap(); + a.run("references.caption", json!({"label": "Figure", "text": "A rock"})).unwrap(); + a.run("caret.docEnd", json!({})).unwrap(); + a.run("insert.crossReference", json!({"to": "figure", "target": "Figure 1", "show": "number"})).unwrap(); + let caption = a.session.doc.body.iter().position(|b| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("Caption"))).unwrap(); + let last = a.session.doc.body.len() - 1; + assert_ne!(caption, last); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ); + steps(&mut h, 6); + // The field sits after "See ": click between its two edges. + let obj = wordcraft_doc::para::OBJ.len_utf8(); + let (before, after) = { + let l = h.state_mut().session.layout(); + let pos = |off| Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(last), off }; + (l.caret(&pos(4)).unwrap(), l.caret(&pos(4 + obj)).unwrap()) + }; + let at = screen(&mut h, (before.x + after.x) / 2.0, before.top + before.height / 2.0); + h.event(egui::Event::PointerMoved(at)); + h.step(); + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND }; + h.event_modifiers(press, egui::Modifiers::COMMAND); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + steps(&mut h, 3); + let s = &h.state().session; + assert_eq!(s.sel.focus.path.last(), caption, "the caret went to the caption: {:?}", s.sel); + } + /// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves /// the scrollable range. #[test]