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 &(mut pos, ref item) in frame.items().rev() {
35 match item {
36 FrameItem::Group(group) => {
37 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
96pub fn jump_from_cursor(document: &TypstDocument, source: &Source, cursor: usize) -> Vec<Position> {
98 jump_from_cursor_(document, source, cursor).unwrap_or_default()
99}
100
101fn jump_from_cursor_(
103 document: &TypstDocument,
104 source: &Source,
105 cursor: usize,
106) -> Option<Vec<Position>> {
107 let node = LinkedNode::new(source.root()).leaf_at_compat(cursor)?;
112 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 let _ = offset;
125
126 match document {
127 TypstDocument::Paged(paged_doc) => {
128 let mut positions = vec![];
131
132 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 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 if p_dis != min_dis {
150 min_page = idx;
151 min_dis = p_dis;
152 }
153 }
154
155 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
169fn 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 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 let is_same_file = glyph.span.0.id() == span.id();
187 if is_same_file {
188 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
208fn 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}