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 layout::{Frame, FrameItem, Point, Position, Size},
11 syntax::{LinkedNode, Source, Span, SyntaxKind},
12 visualize::Geometry,
13};
14use typst_shim::syntax::LinkedNodeExt;
15
16pub fn jump_from_click(
19 world: &LspWorld,
20 frame: &Frame,
21 click: Point,
22) -> Option<(SourceSpanOffset, SourceSpanOffset)> {
23 for (pos, item) in frame.items() {
25 if let FrameItem::Link(_dest, size) = item
26 && is_in_rect(*pos, *size, click)
27 {
28 return None;
30 }
31 }
32
33 for (pos, item) in frame.items().rev() {
35 let mut pos = *pos;
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 (pos, item) in frame.items() {
173 let mut pos = *pos;
174 if let FrameItem::Group(group) = item {
175 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 let is_same_file = glyph.span.0.id() == span.id();
189 if is_same_file {
190 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
210fn 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}