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