1use std::num::NonZeroUsize;
2
3use typst::WorldExt;
4use typst::layout::{Frame, FrameItem, PagedDocument, Point, Position, Size};
5use typst::model::{Destination, Url};
6use typst::syntax::{FileId, LinkedNode, Side, Source, Span, SyntaxKind};
7use typst::visualize::{Curve, CurveItem, FillRule, Geometry};
8
9use crate::IdeWorld;
10
11#[derive(Debug, Clone, Eq, PartialEq)]
13pub enum Jump {
14 File(FileId, usize),
16 Url(Url),
18 Position(Position),
20}
21
22impl Jump {
23 fn from_span(world: &dyn IdeWorld, span: Span) -> Option<Self> {
24 let id = span.id()?;
25 let offset = world.range(span)?.start;
26 Some(Self::File(id, offset))
27 }
28}
29
30pub fn jump_from_click(
32 world: &dyn IdeWorld,
33 document: &PagedDocument,
34 frame: &Frame,
35 click: Point,
36) -> Option<Jump> {
37 for (pos, item) in frame.items() {
39 if let FrameItem::Link(dest, size) = item
40 && is_in_rect(*pos, *size, click)
41 {
42 return Some(match dest {
43 Destination::Url(url) => Jump::Url(url.clone()),
44 Destination::Position(pos) => Jump::Position(*pos),
45 Destination::Location(loc) => {
46 Jump::Position(document.introspector.position(*loc))
47 }
48 });
49 }
50 }
51
52 for &(mut pos, ref item) in frame.items().rev() {
54 match item {
55 FrameItem::Group(group) => {
56 let pos = click - pos;
57 if let Some(clip) = &group.clip
58 && !clip.contains(FillRule::NonZero, pos)
59 {
60 continue;
61 }
62 let Some(inv_transform) = group.transform.invert() else {
66 continue;
67 };
68 let pos = pos.transform_inf(inv_transform);
69 if let Some(span) = jump_from_click(world, document, &group.frame, pos) {
70 return Some(span);
71 }
72 }
73
74 FrameItem::Text(text) => {
75 for glyph in &text.glyphs {
76 let width = glyph.x_advance.at(text.size);
77 if is_in_rect(
78 Point::new(pos.x, pos.y - text.size),
79 Size::new(width, text.size),
80 click,
81 ) {
82 let (span, span_offset) = glyph.span;
83 let Some(id) = span.id() else { continue };
84 let source = world.source(id).ok()?;
85 let node = source.find(span)?;
86 let pos = if matches!(
87 node.kind(),
88 SyntaxKind::Text | SyntaxKind::MathText
89 ) {
90 let range = node.range();
91 let mut offset = range.start + usize::from(span_offset);
92 if (click.x - pos.x) > width / 2.0 {
93 offset += glyph.range().len();
94 }
95 offset.min(range.end)
96 } else {
97 node.offset()
98 };
99 return Some(Jump::File(source.id(), pos));
100 }
101
102 pos.x += width;
103 }
104 }
105
106 FrameItem::Shape(shape, span) => {
107 if shape.fill.is_some() {
108 let within = match &shape.geometry {
109 Geometry::Line(..) => false,
110 Geometry::Rect(size) => is_in_rect(pos, *size, click),
111 Geometry::Curve(curve) => {
112 curve.contains(shape.fill_rule, click - pos)
113 }
114 };
115 if within {
116 return Jump::from_span(world, *span);
117 }
118 }
119
120 if let Some(stroke) = &shape.stroke {
121 let within = !stroke.thickness.approx_empty() && {
122 let base_curve = match &shape.geometry {
124 Geometry::Line(to) => &Curve(vec![CurveItem::Line(*to)]),
125 Geometry::Rect(size) => &Curve::rect(*size),
126 Geometry::Curve(curve) => curve,
127 };
128 base_curve.stroke_contains(stroke, click - pos)
129 };
130 if within {
131 return Jump::from_span(world, *span);
132 }
133 }
134 }
135
136 FrameItem::Image(_, size, span) if is_in_rect(pos, *size, click) => {
137 return Jump::from_span(world, *span);
138 }
139
140 _ => {}
141 }
142 }
143
144 None
145}
146
147pub fn jump_from_cursor(
149 document: &PagedDocument,
150 source: &Source,
151 cursor: usize,
152) -> Vec<Position> {
153 fn is_text(node: &LinkedNode) -> bool {
154 matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText)
155 }
156
157 let root = LinkedNode::new(source.root());
158 let Some(node) = root
159 .leaf_at(cursor, Side::Before)
160 .filter(is_text)
161 .or_else(|| root.leaf_at(cursor, Side::After).filter(is_text))
162 else {
163 return vec![];
164 };
165
166 let span = node.span();
167 document
168 .pages
169 .iter()
170 .enumerate()
171 .filter_map(|(i, page)| {
172 find_in_frame(&page.frame, span)
173 .map(|point| Position { page: NonZeroUsize::new(i + 1).unwrap(), point })
174 })
175 .collect()
176}
177
178fn find_in_frame(frame: &Frame, span: Span) -> Option<Point> {
180 for &(mut pos, ref item) in frame.items() {
181 if let FrameItem::Group(group) = item
182 && let Some(point) = find_in_frame(&group.frame, span)
183 {
184 return Some(pos + point.transform(group.transform));
185 }
186
187 if let FrameItem::Text(text) = item {
188 for glyph in &text.glyphs {
189 if glyph.span.0 == span {
190 return Some(pos);
191 }
192 pos.x += glyph.x_advance.at(text.size);
193 }
194 }
195 }
196
197 None
198}
199
200fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
203 pos.x <= click.x
204 && pos.x + size.x >= click.x
205 && pos.y <= click.y
206 && pos.y + size.y >= click.y
207}
208
209#[cfg(test)]
210mod tests {
211 use std::borrow::Borrow;
221 use std::num::NonZeroUsize;
222
223 use typst::layout::{Abs, Point, Position};
224
225 use super::{Jump, jump_from_click, jump_from_cursor};
226 use crate::tests::{FilePos, TestWorld, WorldLike};
227
228 fn point(x: f64, y: f64) -> Point {
229 Point::new(Abs::pt(x), Abs::pt(y))
230 }
231
232 fn cursor(cursor: usize) -> Option<Jump> {
233 Some(Jump::File(TestWorld::main_id(), cursor))
234 }
235
236 fn pos(page: usize, x: f64, y: f64) -> Option<Position> {
237 Some(Position {
238 page: NonZeroUsize::new(page).unwrap(),
239 point: point(x, y),
240 })
241 }
242
243 macro_rules! assert_approx_eq {
244 ($l:expr, $r:expr) => {
245 assert!(($l - $r).abs() < Abs::pt(0.1), "{:?} ≉ {:?}", $l, $r);
246 };
247 }
248
249 #[track_caller]
250 fn test_click(world: impl WorldLike, click: Point, expected: Option<Jump>) {
251 let world = world.acquire();
252 let world = world.borrow();
253 let doc = typst::compile(world).output.unwrap();
254 let jump = jump_from_click(world, &doc, &doc.pages[0].frame, click);
255 if let (Some(Jump::Position(pos)), Some(Jump::Position(expected))) =
256 (&jump, &expected)
257 {
258 assert_eq!(pos.page, expected.page);
259 assert_approx_eq!(pos.point.x, expected.point.x);
260 assert_approx_eq!(pos.point.y, expected.point.y);
261 } else {
262 assert_eq!(jump, expected);
263 }
264 }
265
266 #[track_caller]
267 fn test_cursor(world: impl WorldLike, pos: impl FilePos, expected: Option<Position>) {
268 let world = world.acquire();
269 let world = world.borrow();
270 let doc = typst::compile(world).output.unwrap();
271 let (source, cursor) = pos.resolve(world);
272 let pos = jump_from_cursor(&doc, &source, cursor);
273 assert_eq!(!pos.is_empty(), expected.is_some());
274 if let (Some(pos), Some(expected)) = (pos.first(), expected) {
275 assert_eq!(pos.page, expected.page);
276 assert_approx_eq!(pos.point.x, expected.point.x);
277 assert_approx_eq!(pos.point.y, expected.point.y);
278 }
279 }
280
281 #[test]
282 fn test_jump_from_click() {
283 let s = "*Hello* #box[ABC] World";
284 test_click(s, point(0.0, 0.0), None);
285 test_click(s, point(70.0, 5.0), None);
286 test_click(s, point(45.0, 15.0), cursor(14));
287 test_click(s, point(48.0, 15.0), cursor(15));
288 test_click(s, point(72.0, 10.0), cursor(20));
289 }
290
291 #[test]
292 fn test_jump_from_click_par_indents() {
293 let s = "#set par(first-line-indent: 1cm, hanging-indent: 1cm);Hello";
296 test_click(s, point(21.0, 12.0), cursor(56));
297 }
298
299 #[test]
300 fn test_jump_from_click_math() {
301 test_click("$a + b$", point(28.0, 14.0), cursor(5));
302 }
303
304 #[test]
305 fn test_jump_from_click_transform_clip() {
306 let margin = point(10.0, 10.0);
307 test_click(
308 "#rect(width: 20pt, height: 20pt, fill: black)",
309 point(10.0, 10.0) + margin,
310 cursor(1),
311 );
312 test_click(
313 "#rect(width: 60pt, height: 10pt, fill: black)",
314 point(5.0, 30.0) + margin,
315 None,
316 );
317 test_click(
318 "#rotate(90deg, origin: bottom + left, rect(width: 60pt, height: 10pt, fill: black))",
319 point(5.0, 30.0) + margin,
320 cursor(38),
321 );
322 test_click(
323 "#scale(x: 300%, y: 300%, origin: top + left, rect(width: 10pt, height: 10pt, fill: black))",
324 point(20.0, 20.0) + margin,
325 cursor(45),
326 );
327 test_click(
328 "#box(width: 10pt, height: 10pt, clip: true, scale(x: 300%, y: 300%, \
329 origin: top + left, rect(width: 10pt, height: 10pt, fill: black)))",
330 point(20.0, 20.0) + margin,
331 None,
332 );
333 test_click(
334 "#box(width: 10pt, height: 10pt, clip: false, rect(width: 30pt, height: 30pt, fill: black))",
335 point(20.0, 20.0) + margin,
336 cursor(45),
337 );
338 test_click(
339 "#box(width: 10pt, height: 10pt, clip: true, rect(width: 30pt, height: 30pt, fill: black))",
340 point(20.0, 20.0) + margin,
341 None,
342 );
343 test_click(
344 "#rotate(90deg, origin: bottom + left)[hello world]",
345 point(5.0, 15.0) + margin,
346 cursor(40),
347 );
348 }
349
350 #[test]
351 fn test_jump_from_click_shapes() {
352 let margin = point(10.0, 10.0);
353
354 test_click(
355 "#rect(width: 30pt, height: 30pt, fill: black)",
356 point(15.0, 15.0) + margin,
357 cursor(1),
358 );
359
360 let circle = "#circle(width: 30pt, height: 30pt, fill: black)";
361 test_click(circle, point(15.0, 15.0) + margin, cursor(1));
362 test_click(circle, point(1.0, 1.0) + margin, None);
363
364 let bowtie =
365 "#polygon(fill: black, (0pt, 0pt), (20pt, 20pt), (20pt, 0pt), (0pt, 20pt))";
366 test_click(bowtie, point(1.0, 2.0) + margin, cursor(1));
367 test_click(bowtie, point(2.0, 1.0) + margin, None);
368 test_click(bowtie, point(19.0, 10.0) + margin, cursor(1));
369
370 let evenodd = r#"#polygon(fill: black, fill-rule: "even-odd",
371 (0pt, 10pt), (30pt, 10pt), (30pt, 20pt), (20pt, 20pt),
372 (20pt, 0pt), (10pt, 0pt), (10pt, 30pt), (20pt, 30pt),
373 (20pt, 20pt), (0pt, 20pt))"#;
374 test_click(evenodd, point(15.0, 15.0) + margin, None);
375 test_click(evenodd, point(5.0, 15.0) + margin, cursor(1));
376 test_click(evenodd, point(15.0, 5.0) + margin, cursor(1));
377 }
378
379 #[test]
380 fn test_jump_from_click_shapes_stroke() {
381 let margin = point(10.0, 10.0);
382
383 let rect =
384 "#place(dx: 10pt, dy: 10pt, rect(width: 10pt, height: 10pt, stroke: 5pt))";
385 test_click(rect, point(15.0, 15.0) + margin, None);
386 test_click(rect, point(10.0, 15.0) + margin, cursor(27));
387
388 test_click(
389 "#line(angle: 45deg, length: 10pt, stroke: 2pt)",
390 point(2.0, 2.0) + margin,
391 cursor(1),
392 );
393 }
394
395 #[test]
396 fn test_jump_from_cursor() {
397 let s = "*Hello* #box[ABC] World";
398 test_cursor(s, 12, None);
399 test_cursor(s, 14, pos(1, 37.55, 16.58));
400 }
401
402 #[test]
403 fn test_jump_from_cursor_math() {
404 test_cursor("$a + b$", -3, pos(1, 27.51, 16.83));
405 }
406
407 #[test]
408 fn test_jump_from_cursor_transform() {
409 test_cursor(
410 r#"#rotate(90deg, origin: bottom + left, [hello world])"#,
411 -5,
412 pos(1, 10.0, 16.58),
413 );
414 }
415
416 #[test]
417 fn test_footnote_links() {
418 let s = "#footnote[Hi]";
419 test_click(s, point(10.0, 10.0), pos(1, 10.0, 31.58).map(Jump::Position));
420 test_click(s, point(19.0, 33.0), pos(1, 10.0, 16.58).map(Jump::Position));
421 }
422
423 #[test]
424 fn test_footnote_link_entry_customized() {
425 let s = "#show footnote.entry: [Replaced]; #footnote[Hi]";
426 test_click(s, point(10.0, 10.0), pos(1, 10.0, 31.58).map(Jump::Position));
427 }
428}