atproto blogging
1//! Conditional syntax visibility based on cursor position.
2//!
3//! Implements Obsidian-style formatting character visibility: syntax markers
4//! are hidden when cursor is not near them, revealed when cursor approaches.
5
6use smol_str::SmolStr;
7
8use crate::paragraph::ParagraphRender;
9use crate::syntax::{SyntaxSpanInfo, SyntaxType};
10use crate::types::Selection;
11use std::collections::HashSet;
12use std::ops::Range;
13
14/// Determines which syntax spans should be visible based on cursor/selection.
15#[derive(Debug, Clone, Default)]
16pub struct VisibilityState {
17 /// Set of syn_ids that should be visible
18 pub visible_span_ids: HashSet<SmolStr>,
19}
20
21impl VisibilityState {
22 /// Calculate visibility based on cursor position and selection.
23 pub fn calculate(
24 cursor_offset: usize,
25 selection: Option<&Selection>,
26 syntax_spans: &[SyntaxSpanInfo],
27 paragraphs: &[ParagraphRender],
28 ) -> Self {
29 let mut visible = HashSet::new();
30
31 for span in syntax_spans {
32 // Find the paragraph containing this span for boundary clamping
33 let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
34
35 let should_show = match span.syntax_type {
36 SyntaxType::Inline => {
37 // Show if cursor within formatted span content OR adjacent to markers
38 // "Adjacent" means within 1 char of the syntax boundaries,
39 // clamped to paragraph bounds (paragraphs are split by newlines,
40 // so clamping to para bounds prevents cross-line extension)
41 let extended_start =
42 safe_extend_left(span.char_range.start, 1, para_bounds.as_ref());
43 let extended_end =
44 safe_extend_right(span.char_range.end, 1, para_bounds.as_ref());
45 let extended_range = extended_start..extended_end;
46
47 // Also show if cursor is anywhere in the formatted_range
48 // (the region between paired opening/closing markers)
49 // Extend by 1 char on BOTH sides for symmetric "approaching" behavior,
50 // clamped to paragraph bounds.
51 let in_formatted_region = span
52 .formatted_range
53 .as_ref()
54 .map(|r| {
55 let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref());
56 let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref());
57 cursor_offset >= ext_start && cursor_offset <= ext_end
58 })
59 .unwrap_or(false);
60
61 let in_extended = extended_range.contains(&cursor_offset);
62 in_extended
63 || in_formatted_region
64 || selection_overlaps(selection, &span.char_range)
65 || span
66 .formatted_range
67 .as_ref()
68 .map(|r| selection_overlaps(selection, r))
69 .unwrap_or(false)
70 }
71 SyntaxType::Block => {
72 // Show if cursor anywhere in same paragraph (with slop for edge cases)
73 // The slop handles typing at the end of a heading like "# |"
74 let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
75 let in_paragraph = para_bounds
76 .as_ref()
77 .map(|p| {
78 // Extend paragraph bounds by 1 char on each side for slop
79 let ext_start = p.start.saturating_sub(1);
80 let ext_end = p.end.saturating_add(1);
81 cursor_offset >= ext_start && cursor_offset <= ext_end
82 })
83 .unwrap_or(false);
84
85 in_paragraph || selection_overlaps(selection, &span.char_range)
86 }
87 };
88
89 if should_show {
90 visible.insert(span.syn_id.clone());
91 }
92 }
93
94 tracing::debug!(
95 target: "weaver::visibility",
96 cursor_offset,
97 total_spans = syntax_spans.len(),
98 visible_count = visible.len(),
99 "calculated visibility"
100 );
101
102 Self {
103 visible_span_ids: visible,
104 }
105 }
106
107 /// Check if a specific span should be visible.
108 pub fn is_visible(&self, syn_id: &str) -> bool {
109 self.visible_span_ids.contains(syn_id)
110 }
111
112 /// Get the number of visible spans.
113 pub fn visible_count(&self) -> usize {
114 self.visible_span_ids.len()
115 }
116
117 /// Check if any spans are visible.
118 pub fn has_visible(&self) -> bool {
119 !self.visible_span_ids.is_empty()
120 }
121}
122
123/// Check if selection overlaps with a char range.
124fn selection_overlaps(selection: Option<&Selection>, range: &Range<usize>) -> bool {
125 let Some(sel) = selection else {
126 return false;
127 };
128
129 let sel_start = sel.start();
130 let sel_end = sel.end();
131
132 // Check if ranges overlap
133 sel_start < range.end && sel_end > range.start
134}
135
136/// Find the paragraph bounds containing a syntax span.
137fn find_paragraph_bounds(
138 syntax_range: &Range<usize>,
139 paragraphs: &[ParagraphRender],
140) -> Option<Range<usize>> {
141 for para in paragraphs {
142 // Skip gap paragraphs
143 if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
144 continue;
145 }
146
147 if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
148 return Some(para.char_range.clone());
149 }
150 }
151 None
152}
153
154/// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds.
155///
156/// Paragraphs are already split by newlines, so clamping to paragraph bounds
157/// naturally prevents extending across line boundaries.
158fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
159 let min_pos = para_bounds.map(|p| p.start).unwrap_or(0);
160 pos.saturating_sub(amount).max(min_pos)
161}
162
163/// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds.
164///
165/// Paragraphs are already split by newlines, so clamping to paragraph bounds
166/// naturally prevents extending across line boundaries.
167fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
168 let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX);
169 pos.saturating_add(amount).min(max_pos)
170}
171
172#[cfg(test)]
173mod tests {
174 use smol_str::{ToSmolStr, format_smolstr};
175
176 use super::*;
177
178 fn make_span(
179 syn_id: &str,
180 start: usize,
181 end: usize,
182 syntax_type: SyntaxType,
183 ) -> SyntaxSpanInfo {
184 SyntaxSpanInfo {
185 syn_id: syn_id.to_smolstr(),
186 char_range: start..end,
187 syntax_type,
188 formatted_range: None,
189 }
190 }
191
192 fn make_span_with_range(
193 syn_id: &str,
194 start: usize,
195 end: usize,
196 syntax_type: SyntaxType,
197 formatted_range: Range<usize>,
198 ) -> SyntaxSpanInfo {
199 SyntaxSpanInfo {
200 syn_id: syn_id.to_smolstr(),
201 char_range: start..end,
202 syntax_type,
203 formatted_range: Some(formatted_range),
204 }
205 }
206
207 fn make_para(start: usize, end: usize, syntax_spans: Vec<SyntaxSpanInfo>) -> ParagraphRender {
208 ParagraphRender {
209 id: format_smolstr!("test-{}-{}", start, end),
210 byte_range: start..end,
211 char_range: start..end,
212 html: String::new(),
213 offset_map: vec![],
214 syntax_spans,
215 source_hash: 0,
216 }
217 }
218
219 #[test]
220 fn test_inline_visibility_cursor_inside() {
221 // **bold** at chars 0-2 (opening **) and 6-8 (closing **)
222 // Text positions: 0-1 = **, 2-5 = bold, 6-7 = **
223 // formatted_range is 0..8 (the whole **bold** region)
224 let spans = vec![
225 make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
226 make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
227 ];
228 let paras = vec![make_para(0, 8, spans.clone())];
229
230 // Cursor at position 4 (middle of "bold", inside formatted region)
231 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
232 assert!(
233 vis.is_visible("s0"),
234 "opening ** should be visible when cursor inside formatted region"
235 );
236 assert!(
237 vis.is_visible("s1"),
238 "closing ** should be visible when cursor inside formatted region"
239 );
240
241 // Cursor at position 2 (adjacent to opening **, start of "bold")
242 let vis = VisibilityState::calculate(2, None, &spans, ¶s);
243 assert!(
244 vis.is_visible("s0"),
245 "opening ** should be visible when cursor adjacent at start of bold"
246 );
247
248 // Cursor at position 5 (adjacent to closing **, end of "bold")
249 let vis = VisibilityState::calculate(5, None, &spans, ¶s);
250 assert!(
251 vis.is_visible("s1"),
252 "closing ** should be visible when cursor adjacent at end of bold"
253 );
254 }
255
256 #[test]
257 fn test_inline_visibility_without_formatted_range() {
258 // Test without formatted_range - just adjacency-based visibility
259 let spans = vec![
260 make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range)
261 make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range)
262 ];
263 let paras = vec![make_para(0, 8, spans.clone())];
264
265 // Cursor at position 4 (middle of "bold", not adjacent to either marker)
266 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
267 assert!(
268 !vis.is_visible("s0"),
269 "opening ** should be hidden when no formatted_range and cursor not adjacent"
270 );
271 assert!(
272 !vis.is_visible("s1"),
273 "closing ** should be hidden when no formatted_range and cursor not adjacent"
274 );
275 }
276
277 #[test]
278 fn test_inline_visibility_cursor_adjacent() {
279 // "test **bold** after"
280 // 5 7
281 let spans = vec![
282 make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6
283 ];
284 let paras = vec![make_para(0, 19, spans.clone())];
285
286 // Cursor at position 4 (one before ** which starts at 5)
287 let vis = VisibilityState::calculate(4, None, &spans, ¶s);
288 assert!(
289 vis.is_visible("s0"),
290 "** should be visible when cursor adjacent"
291 );
292
293 // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
294 let vis = VisibilityState::calculate(7, None, &spans, ¶s);
295 assert!(
296 vis.is_visible("s0"),
297 "** should be visible when cursor adjacent after span"
298 );
299 }
300
301 #[test]
302 fn test_inline_visibility_cursor_far() {
303 let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)];
304 let paras = vec![make_para(0, 33, spans.clone())];
305
306 // Cursor at position 0 (far from **)
307 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
308 assert!(
309 !vis.is_visible("s0"),
310 "** should be hidden when cursor far away"
311 );
312 }
313
314 #[test]
315 fn test_block_visibility_same_paragraph() {
316 // # at start of heading
317 let spans = vec![
318 make_span("s0", 0, 2, SyntaxType::Block), // "# "
319 ];
320 let paras = vec![
321 make_para(0, 10, spans.clone()), // heading paragraph
322 make_para(12, 30, vec![]), // next paragraph
323 ];
324
325 // Cursor at position 5 (inside heading)
326 let vis = VisibilityState::calculate(5, None, &spans, ¶s);
327 assert!(
328 vis.is_visible("s0"),
329 "# should be visible when cursor in same paragraph"
330 );
331 }
332
333 #[test]
334 fn test_block_visibility_different_paragraph() {
335 let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)];
336 let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])];
337
338 // Cursor at position 20 (in second paragraph)
339 let vis = VisibilityState::calculate(20, None, &spans, ¶s);
340 assert!(
341 !vis.is_visible("s0"),
342 "# should be hidden when cursor in different paragraph"
343 );
344 }
345
346 #[test]
347 fn test_selection_reveals_syntax() {
348 let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)];
349 let paras = vec![make_para(0, 24, spans.clone())];
350
351 // Selection overlaps the syntax span
352 let selection = Selection::new(3, 10);
353 let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
354 assert!(
355 vis.is_visible("s0"),
356 "** should be visible when selection overlaps"
357 );
358 }
359
360 #[test]
361 fn test_paragraph_boundary_blocks_extension() {
362 // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1,
363 // even if cursor is only 1 char after the paragraph boundary
364 // (paragraph bounds clamp the extension)
365 let spans = vec![
366 make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
367 make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
368 ];
369 let paras = vec![
370 make_para(0, 8, spans.clone()), // "**bold**"
371 make_para(9, 13, vec![]), // "text" (after newline)
372 ];
373
374 // Cursor at position 9 (start of second paragraph)
375 // Should NOT reveal the closing ** because para bounds clamp extension
376 let vis = VisibilityState::calculate(9, None, &spans, ¶s);
377 assert!(
378 !vis.is_visible("s1"),
379 "closing ** should NOT be visible when cursor is in next paragraph"
380 );
381 }
382
383 #[test]
384 fn test_extension_clamps_to_paragraph() {
385 // Syntax at very start of paragraph - extension left should stop at para start
386 let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)];
387 let paras = vec![make_para(0, 8, spans.clone())];
388
389 // Cursor at position 0 - should still see the opening **
390 let vis = VisibilityState::calculate(0, None, &spans, ¶s);
391 assert!(
392 vis.is_visible("s0"),
393 "** at start should be visible when cursor at position 0"
394 );
395 }
396}