tinymist_query/
jump.rs

1//! Jumping from and to source and the rendered document.
2
3use std::num::NonZeroUsize;
4
5use tinymist_project::LspWorld;
6use tinymist_std::typst::TypstDocument;
7use tinymist_world::debug_loc::SourceSpanOffset;
8use typst::{
9    World,
10    layout::{Frame, FrameItem, Point, Position, Size},
11    syntax::{LinkedNode, Source, Span, SyntaxKind},
12    visualize::Geometry,
13};
14use typst_shim::syntax::LinkedNodeExt;
15
16/// Finds a span range from a clicked physical position in a rendered paged
17/// document.
18pub fn jump_from_click(
19    world: &LspWorld,
20    frame: &Frame,
21    click: Point,
22) -> Option<(SourceSpanOffset, SourceSpanOffset)> {
23    // Try to find a link first.
24    for (pos, item) in frame.items() {
25        if let FrameItem::Link(_dest, size) = item
26            && is_in_rect(*pos, *size, click)
27        {
28            // todo: url reaction
29            return None;
30        }
31    }
32
33    // If there's no link, search for a jump target.
34    for (pos, item) in frame.items().rev() {
35        let mut pos = *pos;
36        match item {
37            FrameItem::Group(group) => {
38                // TODO: Handle transformation.
39                if let Some(span) = jump_from_click(world, &group.frame, click - pos) {
40                    return Some(span);
41                }
42            }
43
44            FrameItem::Text(text) => {
45                for glyph in &text.glyphs {
46                    let width = glyph.x_advance.at(text.size);
47                    if is_in_rect(
48                        Point::new(pos.x, pos.y - text.size),
49                        Size::new(width, text.size),
50                        click,
51                    ) {
52                        let (span, span_offset) = glyph.span;
53                        let mut span_offset = span_offset as usize;
54                        let Some(id) = span.id() else { continue };
55                        let source = world.source(id).ok()?;
56                        let node = source.find(span)?;
57                        if matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText)
58                            && (click.x - pos.x) > width / 2.0
59                        {
60                            span_offset += glyph.range().len();
61                        }
62
63                        let span_offset = SourceSpanOffset {
64                            span,
65                            offset: span_offset,
66                        };
67
68                        return Some((span_offset, span_offset));
69                    }
70
71                    pos.x += width;
72                }
73            }
74
75            FrameItem::Shape(shape, span) => {
76                let Geometry::Rect(size) = shape.geometry else {
77                    continue;
78                };
79                if is_in_rect(pos, size, click) {
80                    let span = (*span).into();
81                    return Some((span, span));
82                }
83            }
84
85            FrameItem::Image(_, size, span) if is_in_rect(pos, *size, click) => {
86                let span = (*span).into();
87                return Some((span, span));
88            }
89
90            _ => {}
91        }
92    }
93
94    None
95}
96
97/// Finds the output location in the document for a cursor position.
98pub fn jump_from_cursor(document: &TypstDocument, source: &Source, cursor: usize) -> Vec<Position> {
99    jump_from_cursor_(document, source, cursor).unwrap_or_default()
100}
101
102/// Finds the output location in the document for a cursor position.
103fn jump_from_cursor_(
104    document: &TypstDocument,
105    source: &Source,
106    cursor: usize,
107) -> Option<Vec<Position>> {
108    // todo: leaf_at_compat only matches the text before the cursor, but we could
109    // also match a text if it is after the cursor
110    // The case `leaf_at_compat` will match: `Hello|`
111    // FIXME: The case `leaf_at_compat` will not match: `|Hello`
112    let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
113    // todo: When we click on a label or some math operators, we seems likely also
114    // be able to jump to some place.
115    if !matches!(node.kind(), SyntaxKind::Text | SyntaxKind::MathText) {
116        return None;
117    };
118
119    let span = node.span();
120    let offset = cursor.saturating_sub(node.offset());
121
122    // todo: The cursor may not exact hit at the start of some AST node. For
123    // example, the cursor in the text element `Hell|o` is offset by 4 from the
124    // node. It seems not pretty if we ignore the offset completely.
125    let _ = offset;
126
127    match document {
128        TypstDocument::Paged(paged_doc) => {
129            // We checks whether there are any elements exactly matching the
130            // cursor position.
131            let mut positions = vec![];
132
133            // Unluckily, we might not be able to find the exact spans, so we
134            // need to find the closest one at the same time.
135            let mut min_page = 0;
136            let mut min_point = Point::default();
137            let mut min_dis = u64::MAX;
138
139            for (idx, page) in paged_doc.pages.iter().enumerate() {
140                // In a page, we try to find a closer span than the existing found one.
141                let mut p_dis = min_dis;
142
143                if let Some(point) = find_in_frame(&page.frame, span, &mut p_dis, &mut min_point)
144                    && let Some(page) = NonZeroUsize::new(idx + 1)
145                {
146                    positions.push(Position { page, point });
147                }
148
149                // In this page, we found a closer span and update.
150                if p_dis != min_dis {
151                    min_page = idx;
152                    min_dis = p_dis;
153                }
154            }
155
156            // If we didn't find any exact span, we add the closest one in the same page.
157            if positions.is_empty() && min_dis != u64::MAX {
158                positions.push(Position {
159                    page: NonZeroUsize::new(min_page + 1)?,
160                    point: min_point,
161                });
162            }
163
164            Some(positions)
165        }
166        _ => None,
167    }
168}
169
170/// Finds the position of a span in a frame.
171fn find_in_frame(frame: &Frame, span: Span, min_dis: &mut u64, res: &mut Point) -> Option<Point> {
172    for (pos, item) in frame.items() {
173        let mut pos = *pos;
174        if let FrameItem::Group(group) = item {
175            // TODO: Handle transformation.
176            if let Some(point) = find_in_frame(&group.frame, span, min_dis, res) {
177                return Some(point + pos);
178            }
179        }
180
181        if let FrameItem::Text(text) = item {
182            for glyph in &text.glyphs {
183                if glyph.span.0 == span {
184                    return Some(pos);
185                }
186
187                // We at least require that the span is in the same file.
188                let is_same_file = glyph.span.0.id() == span.id();
189                if is_same_file {
190                    // The numbers are not offsets but a unique id on the AST tree which are
191                    // nicely divided.
192                    // FIXME: since typst v0.13.0, the numbers are not only the ids, but also raw
193                    // ranges, See [`Span::range`].
194                    let glyph_num = glyph.span.0.into_raw();
195                    let span_num = span.into_raw().get();
196                    let dis = glyph_num.get().abs_diff(span_num);
197                    if dis < *min_dis {
198                        *min_dis = dis;
199                        *res = pos;
200                    }
201                }
202                pos.x += glyph.x_advance.at(text.size);
203            }
204        }
205    }
206
207    None
208}
209
210/// Whether a rectangle with the given size at the given position contains the
211/// click position.
212fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
213    pos.x <= click.x && pos.x + size.x >= click.x && pos.y <= click.y && pos.y + size.y >= click.y
214}
215
216#[cfg(test)]
217mod tests {
218    use itertools::Itertools;
219
220    use super::*;
221    use crate::tests::*;
222
223    #[test]
224    fn test() {
225        snapshot_testing("jump_from_cursor", &|ctx, path| {
226            let source = ctx.source_by_path(&path).unwrap();
227            let docs = find_module_level_docs(&source).unwrap_or_default();
228            let properties = get_test_properties(&docs);
229
230            let graph = compile_doc_for_test(ctx, &properties);
231            let document = graph.snap.success_doc.as_ref().unwrap();
232
233            let cursors = find_test_range_(&source);
234
235            let results = cursors
236                .map(|cursor| {
237                    let points = jump_from_cursor(document, &source, cursor);
238
239                    if points.is_empty() {
240                        return "nothing".to_string();
241                    }
242
243                    points
244                        .iter()
245                        .map(|pos| {
246                            let page = pos.page.get();
247                            let point = pos.point;
248                            format!("{page},{:.3}pt,{:.3}pt", point.x.to_pt(), point.y.to_pt())
249                        })
250                        .join(";")
251                })
252                .join("\n");
253
254            with_settings!({
255                description => format!("Jump cursor on {})", make_range_annotation(&source)),
256            }, {
257                assert_snapshot!(results);
258            })
259        });
260    }
261}