1use 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
17pub fn jump_from_click(
20 world: &LspWorld,
21 frame: &Frame,
22 click: Point,
23) -> Option<(SourceSpanOffset, SourceSpanOffset)> {
24 for (pos, item) in frame.items() {
26 if let FrameItem::Link(_dest, size) = item
27 && is_in_rect(*pos, *size, click)
28 {
29 return None;
31 }
32 }
33
34 for &(mut pos, ref item) in frame.items().rev() {
36 match item {
37 FrameItem::Group(group) => {
38 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
97pub fn jump_from_cursor(document: &TypstDocument, source: &Source, cursor: usize) -> Vec<Position> {
99 jump_from_cursor_(document, source, cursor).unwrap_or_default()
100}
101
102fn jump_from_cursor_(
104 document: &TypstDocument,
105 source: &Source,
106 cursor: usize,
107) -> Option<Vec<Position>> {
108 let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
113 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 let _ = offset;
126
127 match document {
128 TypstDocument::Paged(paged_doc) => {
129 let mut positions = vec![];
132
133 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 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 if p_dis != min_dis {
151 min_page = idx;
152 min_dis = p_dis;
153 }
154 }
155
156 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
170fn 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 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 let is_same_file = glyph.span.0.id() == span.id();
188 if is_same_file {
189 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
209fn 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}