at main 396 lines 15 kB view raw
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, &paras); 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, &paras); 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, &paras); 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, &paras); 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, &paras); 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, &paras); 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, &paras); 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, &paras); 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, &paras); 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, &paras); 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, &paras); 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, &paras); 391 assert!( 392 vis.is_visible("s0"), 393 "** at start should be visible when cursor at position 0" 394 ); 395 } 396}