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    introspection::PagedPosition as Position,
11    layout::{Frame, FrameItem, Point, Size},
12    syntax::{LinkedNode, Source, Span, SyntaxKind},
13    visualize::Geometry,
14};
15use typst_shim::syntax::LinkedNodeExt;
16
17/// Finds a span range from a clicked physical position in a rendered paged
18/// document.
19pub fn jump_from_click(
20    world: &LspWorld,
21    frame: &Frame,
22    click: Point,
23) -> Option<(SourceSpanOffset, SourceSpanOffset)> {
24    // Try to find a link first.
25    for (pos, item) in frame.items() {
26        if let FrameItem::Link(_dest, size) = item
27            && is_in_rect(*pos, *size, click)
28        {
29            // todo: url reaction
30            return None;
31        }
32    }
33
34    // If there's no link, search for a jump target.
35    for &(mut pos, ref item) in frame.items().rev() {
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 &(mut pos, ref item) in frame.items() {
173        if let FrameItem::Group(group) = item {
174            // TODO: Handle transformation.
175            if let Some(point) = find_in_frame(&group.frame, span, min_dis, res) {
176                return Some(point + pos);
177            }
178        }
179
180        if let FrameItem::Text(text) = item {
181            for glyph in &text.glyphs {
182                if glyph.span.0 == span {
183                    return Some(pos);
184                }
185
186                // We at least require that the span is in the same file.
187                let is_same_file = glyph.span.0.id() == span.id();
188                if is_same_file {
189                    // The numbers are not offsets but a unique id on the AST tree which are
190                    // nicely divided.
191                    // FIXME: since typst v0.13.0, the numbers are not only the ids, but also raw
192                    // ranges, See [`Span::range`].
193                    let glyph_num = glyph.span.0.into_raw();
194                    let span_num = span.into_raw().get();
195                    let dis = glyph_num.get().abs_diff(span_num);
196                    if dis < *min_dis {
197                        *min_dis = dis;
198                        *res = pos;
199                    }
200                }
201                pos.x += glyph.x_advance.at(text.size);
202            }
203        }
204    }
205
206    None
207}
208
209/// Whether a rectangle with the given size at the given position contains the
210/// click position.
211fn is_in_rect(pos: Point, size: Size, click: Point) -> bool {
212    pos.x <= click.x && pos.x + size.x >= click.x && pos.y <= click.y && pos.y + size.y >= click.y
213}
214
215#[cfg(test)]
216mod tests {
217    use itertools::Itertools;
218
219    use super::*;
220    use crate::tests::*;
221
222    #[test]
223    fn test() {
224        snapshot_testing("jump_from_cursor", &|ctx, path| {
225            let source = ctx.source_by_path(&path).unwrap();
226            let document = ctx.success_doc().unwrap();
227
228            let cursors = find_test_range_(&source);
229
230            let results = cursors
231                .map(|cursor| {
232                    let points = jump_from_cursor(document, &source, cursor);
233
234                    if points.is_empty() {
235                        return "nothing".to_string();
236                    }
237
238                    points
239                        .iter()
240                        .map(|pos| {
241                            let page = pos.page.get();
242                            let point = pos.point;
243                            format!("{page},{:.3}pt,{:.3}pt", point.x.to_pt(), point.y.to_pt())
244                        })
245                        .join(";")
246                })
247                .join("\n");
248
249            with_settings!({
250                description => format!("Jump cursor on {})", make_range_annotation(&source)),
251            }, {
252                assert_snapshot!(results);
253            })
254        });
255    }
256}