editor extraction

Orual 13db455b cdbdcce8

+1835 -5
+27
Cargo.lock
··· 9084 9084 ] 9085 9085 9086 9086 [[package]] 9087 + name = "ropey" 9088 + version = "1.6.1" 9089 + source = "registry+https://github.com/rust-lang/crates.io-index" 9090 + checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" 9091 + dependencies = [ 9092 + "smallvec", 9093 + "str_indices", 9094 + ] 9095 + 9096 + [[package]] 9087 9097 name = "rouille" 9088 9098 version = "3.6.2" 9089 9099 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 10197 10207 version = "1.1.0" 10198 10208 source = "registry+https://github.com/rust-lang/crates.io-index" 10199 10209 checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 10210 + 10211 + [[package]] 10212 + name = "str_indices" 10213 + version = "0.4.4" 10214 + source = "registry+https://github.com/rust-lang/crates.io-index" 10215 + checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" 10200 10216 10201 10217 [[package]] 10202 10218 name = "strict-num" ··· 12102 12118 "wasmworker", 12103 12119 "wasmworker-proc-macro", 12104 12120 "weaver-api", 12121 + "web-time", 12122 + ] 12123 + 12124 + [[package]] 12125 + name = "weaver-editor-core" 12126 + version = "0.1.0" 12127 + dependencies = [ 12128 + "ropey", 12129 + "smol_str", 12130 + "thiserror 2.0.17", 12131 + "tracing", 12105 12132 "web-time", 12106 12133 ] 12107 12134
+3 -3
Cargo.toml
··· 80 80 [profile.wasm-dev] 81 81 inherits = "dev" 82 82 debug = true 83 - lto = true 83 + #lto = true 84 84 85 85 [profile.server-dev] 86 86 inherits = "dev" 87 87 debug = true 88 - lto = true 88 + #lto = true 89 89 90 90 91 91 [profile.server-release] 92 92 inherits = "release" 93 93 opt-level = 2 94 - lto = true 94 + #lto = true 95 95 96 96 [profile.android-dev] 97 97 inherits = "dev"
+14
crates/weaver-editor-core/Cargo.toml
··· 1 + [package] 2 + name = "weaver-editor-core" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + 7 + [dependencies] 8 + ropey = "1.6" 9 + smol_str = "0.3" 10 + thiserror = { workspace = true } 11 + tracing = { workspace = true } 12 + web-time = "1.1" 13 + 14 + [dev-dependencies]
+27
crates/weaver-editor-core/src/lib.rs
··· 1 + //! weaver-editor-core: Pure Rust editor logic without framework dependencies. 2 + //! 3 + //! This crate provides: 4 + //! - `TextBuffer` trait for text storage abstraction 5 + //! - `EditorRope` - ropey-backed implementation 6 + //! - `EditorDocument<T>` - generic document with undo support 7 + //! - Rendering, actions, formatting - all generic over TextBuffer 8 + 9 + pub mod offset_map; 10 + pub mod paragraph; 11 + pub mod syntax; 12 + pub mod text; 13 + pub mod types; 14 + pub mod visibility; 15 + 16 + pub use offset_map::{ 17 + OffsetMapping, RenderResult, SnapDirection, SnappedPosition, find_mapping_for_byte, 18 + find_mapping_for_char, find_nearest_valid_position, is_valid_cursor_position, 19 + }; 20 + pub use paragraph::{ParagraphRender, hash_source, make_paragraph_id}; 21 + pub use smol_str::SmolStr; 22 + pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; 23 + pub use text::{EditorRope, TextBuffer}; 24 + pub use types::{ 25 + Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE, 26 + }; 27 + pub use visibility::VisibilityState;
+524
crates/weaver-editor-core/src/offset_map.rs
··· 1 + //! Offset mapping between source text and rendered DOM. 2 + //! 3 + //! When rendering markdown to HTML, some characters disappear (table pipes) 4 + //! and content gets split across nodes (syntax highlighting). Offset maps 5 + //! track how source byte positions map to DOM node positions. 6 + 7 + use std::ops::Range; 8 + 9 + /// Result of rendering markdown with offset tracking. 10 + #[derive(Debug, Clone, PartialEq)] 11 + pub struct RenderResult { 12 + /// Rendered HTML string 13 + pub html: String, 14 + 15 + /// Mappings from source bytes to DOM positions 16 + pub offset_map: Vec<OffsetMapping>, 17 + } 18 + 19 + /// Maps a source range to a position in the rendered DOM. 20 + /// 21 + /// # Example 22 + /// 23 + /// Source: `| foo | bar |` 24 + /// Bytes: 0 2-5 7-10 12 25 + /// Chars: 0 2-5 7-10 12 (ASCII, so same) 26 + /// 27 + /// Rendered: 28 + /// ```html 29 + /// <table id="t0"> 30 + /// <tr><td id="t0-c0">foo</td><td id="t0-c1">bar</td></tr> 31 + /// </table> 32 + /// ``` 33 + /// 34 + /// Mappings: 35 + /// - `{ byte_range: 0..2, char_range: 0..2, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 0 }` - "| " invisible 36 + /// - `{ byte_range: 2..5, char_range: 2..5, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 3 }` - "foo" visible 37 + /// - `{ byte_range: 5..7, char_range: 5..7, node_id: "t0-c0", char_offset_in_node: 3, utf16_len: 0 }` - " |" invisible 38 + /// - etc. 39 + #[derive(Debug, Clone, PartialEq)] 40 + pub struct OffsetMapping { 41 + /// Source byte range (UTF-8 bytes, from parser) 42 + pub byte_range: Range<usize>, 43 + 44 + /// Source char range (Unicode scalar values, for rope indexing) 45 + pub char_range: Range<usize>, 46 + 47 + /// DOM node ID containing this content 48 + /// For invisible content, this is the nearest visible container 49 + pub node_id: String, 50 + 51 + /// Position within the node 52 + /// - If child_index is Some: cursor at that child index in the element 53 + /// - If child_index is None: UTF-16 offset in text content 54 + pub char_offset_in_node: usize, 55 + 56 + /// If Some, position cursor at this child index in the element (not in text) 57 + /// Used for positions after <br /> or at empty lines 58 + pub child_index: Option<usize>, 59 + 60 + /// Length of this mapping in UTF-16 chars in DOM 61 + /// If 0, these source bytes aren't rendered (table pipes, etc) 62 + pub utf16_len: usize, 63 + } 64 + 65 + impl OffsetMapping { 66 + /// Check if this mapping contains the given byte offset 67 + pub fn contains_byte(&self, byte_offset: usize) -> bool { 68 + self.byte_range.contains(&byte_offset) 69 + } 70 + 71 + /// Check if this mapping contains the given char offset 72 + pub fn contains_char(&self, char_offset: usize) -> bool { 73 + self.char_range.contains(&char_offset) 74 + } 75 + 76 + /// Check if this mapping represents invisible content 77 + pub fn is_invisible(&self) -> bool { 78 + self.utf16_len == 0 79 + } 80 + } 81 + 82 + /// Find the offset mapping containing the given byte offset. 83 + /// 84 + /// Returns the mapping and whether the cursor should snap to the next 85 + /// visible position (for invisible content). 86 + pub fn find_mapping_for_byte( 87 + offset_map: &[OffsetMapping], 88 + byte_offset: usize, 89 + ) -> Option<(&OffsetMapping, bool)> { 90 + // Binary search for the mapping 91 + // Note: We allow cursor at the end boundary of a mapping (cursor after text) 92 + let idx = offset_map 93 + .binary_search_by(|mapping| { 94 + if mapping.byte_range.end < byte_offset { 95 + std::cmp::Ordering::Less 96 + } else if mapping.byte_range.start > byte_offset { 97 + std::cmp::Ordering::Greater 98 + } else { 99 + std::cmp::Ordering::Equal 100 + } 101 + }) 102 + .ok()?; 103 + 104 + let mapping = &offset_map[idx]; 105 + let should_snap = mapping.is_invisible(); 106 + 107 + Some((mapping, should_snap)) 108 + } 109 + 110 + /// Find the offset mapping containing the given char offset. 111 + /// 112 + /// This is the primary lookup method for cursor restoration, since 113 + /// cursor positions are tracked as char offsets in the rope. 114 + /// 115 + /// Returns the mapping and whether the cursor should snap to the next 116 + /// visible position (for invisible content). 117 + pub fn find_mapping_for_char( 118 + offset_map: &[OffsetMapping], 119 + char_offset: usize, 120 + ) -> Option<(&OffsetMapping, bool)> { 121 + // Binary search for the mapping 122 + // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9. 123 + // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20), 124 + // prefer the NEXT mapping so cursor goes "down" to new content. 125 + let result = offset_map.binary_search_by(|mapping| { 126 + if mapping.char_range.end <= char_offset { 127 + // Cursor is at or after end of this mapping - look forward 128 + std::cmp::Ordering::Less 129 + } else if mapping.char_range.start > char_offset { 130 + // Cursor is before this mapping 131 + std::cmp::Ordering::Greater 132 + } else { 133 + // Cursor is within [start, end) 134 + std::cmp::Ordering::Equal 135 + } 136 + }); 137 + 138 + let mapping = match result { 139 + Ok(idx) => &offset_map[idx], 140 + Err(idx) => { 141 + // No exact match - cursor is at boundary between mappings (or past end) 142 + // If cursor is exactly at end of previous mapping, return that mapping 143 + // This handles cursor at end of document or end of last mapping 144 + if idx > 0 && offset_map[idx - 1].char_range.end == char_offset { 145 + &offset_map[idx - 1] 146 + } else { 147 + return None; 148 + } 149 + } 150 + }; 151 + 152 + let should_snap = mapping.is_invisible(); 153 + Some((mapping, should_snap)) 154 + } 155 + 156 + /// Direction hint for cursor snapping. 157 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 158 + pub enum SnapDirection { 159 + Backward, 160 + Forward, 161 + } 162 + 163 + /// Result of finding a valid cursor position. 164 + #[derive(Debug, Clone)] 165 + pub struct SnappedPosition<'a> { 166 + pub mapping: &'a OffsetMapping, 167 + pub offset_in_mapping: usize, 168 + pub snapped: Option<SnapDirection>, 169 + } 170 + 171 + impl SnappedPosition<'_> { 172 + /// Get the absolute char offset for this position. 173 + pub fn char_offset(&self) -> usize { 174 + self.mapping.char_range.start + self.offset_in_mapping 175 + } 176 + } 177 + 178 + /// Find the nearest valid cursor position to a char offset. 179 + /// 180 + /// A valid position is one that maps to visible content (utf16_len > 0). 181 + /// If the position is already valid, returns it directly. Otherwise, 182 + /// searches in the preferred direction first, falling back to the other 183 + /// direction if needed. 184 + pub fn find_nearest_valid_position( 185 + offset_map: &[OffsetMapping], 186 + char_offset: usize, 187 + preferred_direction: Option<SnapDirection>, 188 + ) -> Option<SnappedPosition<'_>> { 189 + if offset_map.is_empty() { 190 + return None; 191 + } 192 + 193 + // Try exact match first 194 + if let Some((mapping, should_snap)) = find_mapping_for_char(offset_map, char_offset) { 195 + if !should_snap { 196 + // Position is valid, return it directly 197 + let offset_in_mapping = char_offset.saturating_sub(mapping.char_range.start); 198 + return Some(SnappedPosition { 199 + mapping, 200 + offset_in_mapping, 201 + snapped: None, 202 + }); 203 + } 204 + } 205 + 206 + // Position is invalid or not found - search for nearest valid 207 + let search_order = match preferred_direction { 208 + Some(SnapDirection::Backward) => [SnapDirection::Backward, SnapDirection::Forward], 209 + Some(SnapDirection::Forward) | None => [SnapDirection::Forward, SnapDirection::Backward], 210 + }; 211 + 212 + for direction in search_order { 213 + if let Some(pos) = find_valid_in_direction(offset_map, char_offset, direction) { 214 + return Some(pos); 215 + } 216 + } 217 + 218 + None 219 + } 220 + 221 + /// Search for a valid position in a specific direction. 222 + fn find_valid_in_direction( 223 + offset_map: &[OffsetMapping], 224 + char_offset: usize, 225 + direction: SnapDirection, 226 + ) -> Option<SnappedPosition<'_>> { 227 + match direction { 228 + SnapDirection::Forward => { 229 + // Find first visible mapping at or after char_offset 230 + for mapping in offset_map { 231 + if mapping.char_range.start >= char_offset && !mapping.is_invisible() { 232 + return Some(SnappedPosition { 233 + mapping, 234 + offset_in_mapping: 0, 235 + snapped: Some(SnapDirection::Forward), 236 + }); 237 + } 238 + // Also check if char_offset falls within this visible mapping 239 + if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { 240 + let offset_in_mapping = char_offset - mapping.char_range.start; 241 + return Some(SnappedPosition { 242 + mapping, 243 + offset_in_mapping, 244 + snapped: Some(SnapDirection::Forward), 245 + }); 246 + } 247 + } 248 + None 249 + } 250 + SnapDirection::Backward => { 251 + // Find last visible mapping at or before char_offset 252 + for mapping in offset_map.iter().rev() { 253 + if mapping.char_range.end <= char_offset && !mapping.is_invisible() { 254 + // Snap to end of this mapping 255 + let offset_in_mapping = mapping.char_range.len(); 256 + return Some(SnappedPosition { 257 + mapping, 258 + offset_in_mapping, 259 + snapped: Some(SnapDirection::Backward), 260 + }); 261 + } 262 + // Also check if char_offset falls within this visible mapping 263 + if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { 264 + let offset_in_mapping = char_offset - mapping.char_range.start; 265 + return Some(SnappedPosition { 266 + mapping, 267 + offset_in_mapping, 268 + snapped: Some(SnapDirection::Backward), 269 + }); 270 + } 271 + } 272 + None 273 + } 274 + } 275 + } 276 + 277 + /// Check if a char offset is at a valid (non-invisible) cursor position. 278 + pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { 279 + find_mapping_for_char(offset_map, char_offset) 280 + .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) 281 + .unwrap_or(false) 282 + } 283 + 284 + #[cfg(test)] 285 + mod tests { 286 + use super::*; 287 + 288 + #[test] 289 + fn test_find_mapping_by_byte() { 290 + let mappings = vec![ 291 + OffsetMapping { 292 + byte_range: 0..2, 293 + char_range: 0..2, 294 + node_id: "n0".to_string(), 295 + char_offset_in_node: 0, 296 + child_index: None, 297 + utf16_len: 0, // invisible 298 + }, 299 + OffsetMapping { 300 + byte_range: 2..5, 301 + char_range: 2..5, 302 + node_id: "n0".to_string(), 303 + char_offset_in_node: 0, 304 + child_index: None, 305 + utf16_len: 3, 306 + }, 307 + OffsetMapping { 308 + byte_range: 5..7, 309 + char_range: 5..7, 310 + node_id: "n0".to_string(), 311 + char_offset_in_node: 3, 312 + child_index: None, 313 + utf16_len: 0, // invisible 314 + }, 315 + ]; 316 + 317 + // Byte 0 (invisible) 318 + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 0).unwrap(); 319 + assert_eq!(mapping.byte_range, 0..2); 320 + assert!(should_snap); 321 + 322 + // Byte 3 (visible) 323 + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 3).unwrap(); 324 + assert_eq!(mapping.byte_range, 2..5); 325 + assert!(!should_snap); 326 + 327 + // Byte 6 (invisible) 328 + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 6).unwrap(); 329 + assert_eq!(mapping.byte_range, 5..7); 330 + assert!(should_snap); 331 + } 332 + 333 + #[test] 334 + fn test_find_mapping_by_char() { 335 + let mappings = vec![ 336 + OffsetMapping { 337 + byte_range: 0..2, 338 + char_range: 0..2, 339 + node_id: "n0".to_string(), 340 + char_offset_in_node: 0, 341 + child_index: None, 342 + utf16_len: 0, // invisible 343 + }, 344 + OffsetMapping { 345 + byte_range: 2..5, 346 + char_range: 2..5, 347 + node_id: "n0".to_string(), 348 + char_offset_in_node: 0, 349 + child_index: None, 350 + utf16_len: 3, 351 + }, 352 + OffsetMapping { 353 + byte_range: 5..7, 354 + char_range: 5..7, 355 + node_id: "n0".to_string(), 356 + char_offset_in_node: 3, 357 + child_index: None, 358 + utf16_len: 0, // invisible 359 + }, 360 + ]; 361 + 362 + // Char 0 (invisible) 363 + let (mapping, should_snap) = find_mapping_for_char(&mappings, 0).unwrap(); 364 + assert_eq!(mapping.char_range, 0..2); 365 + assert!(should_snap); 366 + 367 + // Char 3 (visible) 368 + let (mapping, should_snap) = find_mapping_for_char(&mappings, 3).unwrap(); 369 + assert_eq!(mapping.char_range, 2..5); 370 + assert!(!should_snap); 371 + 372 + // Char 6 (invisible) 373 + let (mapping, should_snap) = find_mapping_for_char(&mappings, 6).unwrap(); 374 + assert_eq!(mapping.char_range, 5..7); 375 + assert!(should_snap); 376 + } 377 + 378 + #[test] 379 + fn test_contains_byte() { 380 + let mapping = OffsetMapping { 381 + byte_range: 10..20, 382 + char_range: 10..20, 383 + node_id: "test".to_string(), 384 + char_offset_in_node: 0, 385 + child_index: None, 386 + utf16_len: 5, 387 + }; 388 + 389 + assert!(!mapping.contains_byte(9)); 390 + assert!(mapping.contains_byte(10)); 391 + assert!(mapping.contains_byte(15)); 392 + assert!(mapping.contains_byte(19)); 393 + assert!(!mapping.contains_byte(20)); 394 + } 395 + 396 + #[test] 397 + fn test_contains_char() { 398 + let mapping = OffsetMapping { 399 + byte_range: 10..20, 400 + char_range: 8..15, // emoji example: fewer chars than bytes 401 + node_id: "test".to_string(), 402 + char_offset_in_node: 0, 403 + child_index: None, 404 + utf16_len: 5, 405 + }; 406 + 407 + assert!(!mapping.contains_char(7)); 408 + assert!(mapping.contains_char(8)); 409 + assert!(mapping.contains_char(12)); 410 + assert!(mapping.contains_char(14)); 411 + assert!(!mapping.contains_char(15)); 412 + } 413 + 414 + fn make_test_mappings() -> Vec<OffsetMapping> { 415 + vec![ 416 + OffsetMapping { 417 + byte_range: 0..2, 418 + char_range: 0..2, 419 + node_id: "n0".to_string(), 420 + char_offset_in_node: 0, 421 + child_index: None, 422 + utf16_len: 0, // invisible: "![" 423 + }, 424 + OffsetMapping { 425 + byte_range: 2..5, 426 + char_range: 2..5, 427 + node_id: "n0".to_string(), 428 + char_offset_in_node: 0, 429 + child_index: None, 430 + utf16_len: 3, // visible: "alt" 431 + }, 432 + OffsetMapping { 433 + byte_range: 5..15, 434 + char_range: 5..15, 435 + node_id: "n0".to_string(), 436 + char_offset_in_node: 3, 437 + child_index: None, 438 + utf16_len: 0, // invisible: "](url.png)" 439 + }, 440 + OffsetMapping { 441 + byte_range: 15..20, 442 + char_range: 15..20, 443 + node_id: "n0".to_string(), 444 + char_offset_in_node: 3, 445 + child_index: None, 446 + utf16_len: 5, // visible: " text" 447 + }, 448 + ] 449 + } 450 + 451 + #[test] 452 + fn test_find_nearest_valid_position_exact_match() { 453 + let mappings = make_test_mappings(); 454 + 455 + // Position 3 is in visible mapping (2..5) 456 + let pos = find_nearest_valid_position(&mappings, 3, None).unwrap(); 457 + assert_eq!(pos.char_offset(), 3); 458 + assert!(pos.snapped.is_none()); 459 + } 460 + 461 + #[test] 462 + fn test_find_nearest_valid_position_snap_forward() { 463 + let mappings = make_test_mappings(); 464 + 465 + // Position 0 is invisible, should snap forward to 2 466 + let pos = find_nearest_valid_position(&mappings, 0, Some(SnapDirection::Forward)).unwrap(); 467 + assert_eq!(pos.char_offset(), 2); 468 + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 469 + } 470 + 471 + #[test] 472 + fn test_find_nearest_valid_position_snap_backward() { 473 + let mappings = make_test_mappings(); 474 + 475 + // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5) 476 + let pos = 477 + find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); 478 + assert_eq!(pos.char_offset(), 5); // end of "alt" mapping 479 + assert_eq!(pos.snapped, Some(SnapDirection::Backward)); 480 + } 481 + 482 + #[test] 483 + fn test_find_nearest_valid_position_default_forward() { 484 + let mappings = make_test_mappings(); 485 + 486 + // Position 0 is invisible, None direction defaults to forward 487 + let pos = find_nearest_valid_position(&mappings, 0, None).unwrap(); 488 + assert_eq!(pos.char_offset(), 2); 489 + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 490 + } 491 + 492 + #[test] 493 + fn test_find_nearest_valid_position_snap_forward_from_invisible() { 494 + let mappings = make_test_mappings(); 495 + 496 + // Position 10 is in invisible range (5..15), forward finds visible (15..20) 497 + let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Forward)).unwrap(); 498 + assert_eq!(pos.char_offset(), 15); 499 + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); 500 + } 501 + 502 + #[test] 503 + fn test_is_valid_cursor_position() { 504 + let mappings = make_test_mappings(); 505 + 506 + // Invisible positions 507 + assert!(!is_valid_cursor_position(&mappings, 0)); 508 + assert!(!is_valid_cursor_position(&mappings, 1)); 509 + assert!(!is_valid_cursor_position(&mappings, 10)); 510 + 511 + // Visible positions 512 + assert!(is_valid_cursor_position(&mappings, 2)); 513 + assert!(is_valid_cursor_position(&mappings, 3)); 514 + assert!(is_valid_cursor_position(&mappings, 4)); 515 + assert!(is_valid_cursor_position(&mappings, 15)); 516 + assert!(is_valid_cursor_position(&mappings, 17)); 517 + } 518 + 519 + #[test] 520 + fn test_find_nearest_valid_position_empty() { 521 + let mappings: Vec<OffsetMapping> = vec![]; 522 + assert!(find_nearest_valid_position(&mappings, 0, None).is_none()); 523 + } 524 + }
+122
crates/weaver-editor-core/src/paragraph.rs
··· 1 + //! Paragraph-level rendering for incremental updates. 2 + //! 3 + //! Paragraphs are discovered during markdown rendering by tracking 4 + //! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. 5 + 6 + use smol_str::{SmolStr, format_smolstr}; 7 + 8 + use crate::offset_map::OffsetMapping; 9 + use crate::syntax::SyntaxSpanInfo; 10 + use std::collections::hash_map::DefaultHasher; 11 + use std::hash::{Hash, Hasher}; 12 + use std::ops::Range; 13 + 14 + /// A rendered paragraph with its source range and offset mappings. 15 + #[derive(Debug, Clone, PartialEq)] 16 + pub struct ParagraphRender { 17 + /// Stable content-based ID for DOM diffing (format: `p-{index}`) 18 + pub id: SmolStr, 19 + 20 + /// Source byte range in the text buffer 21 + pub byte_range: Range<usize>, 22 + 23 + /// Source char range in the text buffer 24 + pub char_range: Range<usize>, 25 + 26 + /// Rendered HTML content (without wrapper div) 27 + pub html: String, 28 + 29 + /// Offset mappings for this paragraph 30 + pub offset_map: Vec<OffsetMapping>, 31 + 32 + /// Syntax spans for conditional visibility 33 + pub syntax_spans: Vec<SyntaxSpanInfo>, 34 + 35 + /// Hash of source text for quick change detection 36 + pub source_hash: u64, 37 + } 38 + 39 + impl ParagraphRender { 40 + /// Check if this paragraph contains a given byte offset. 41 + pub fn contains_byte(&self, offset: usize) -> bool { 42 + self.byte_range.contains(&offset) 43 + } 44 + 45 + /// Check if this paragraph contains a given char offset. 46 + pub fn contains_char(&self, offset: usize) -> bool { 47 + self.char_range.contains(&offset) 48 + } 49 + 50 + /// Get the length in chars. 51 + pub fn char_len(&self) -> usize { 52 + self.char_range.len() 53 + } 54 + 55 + /// Get the length in bytes. 56 + pub fn byte_len(&self) -> usize { 57 + self.byte_range.len() 58 + } 59 + } 60 + 61 + /// Simple hash function for source text comparison. 62 + /// 63 + /// Used to quickly detect if paragraph content has changed. 64 + pub fn hash_source(text: &str) -> u64 { 65 + let mut hasher = DefaultHasher::new(); 66 + text.hash(&mut hasher); 67 + hasher.finish() 68 + } 69 + 70 + /// Generate a paragraph ID from monotonic counter. 71 + /// 72 + /// IDs are stable across content changes - only position/cursor determines identity. 73 + pub fn make_paragraph_id(index: usize) -> SmolStr { 74 + format_smolstr!("p-{}", index) 75 + } 76 + 77 + #[cfg(test)] 78 + mod tests { 79 + use smol_str::ToSmolStr; 80 + 81 + use super::*; 82 + 83 + #[test] 84 + fn test_hash_source() { 85 + let h1 = hash_source("hello world"); 86 + let h2 = hash_source("hello world"); 87 + let h3 = hash_source("hello world!"); 88 + 89 + assert_eq!(h1, h2); 90 + assert_ne!(h1, h3); 91 + } 92 + 93 + #[test] 94 + fn test_make_paragraph_id() { 95 + assert_eq!(make_paragraph_id(0), "p-0"); 96 + assert_eq!(make_paragraph_id(42), "p-42"); 97 + } 98 + 99 + #[test] 100 + fn test_paragraph_contains() { 101 + let para = ParagraphRender { 102 + id: "p-0".to_smolstr(), 103 + byte_range: 10..50, 104 + char_range: 10..50, 105 + html: String::new(), 106 + offset_map: vec![], 107 + syntax_spans: vec![], 108 + source_hash: 0, 109 + }; 110 + 111 + assert!(!para.contains_byte(9)); 112 + assert!(para.contains_byte(10)); 113 + assert!(para.contains_byte(25)); 114 + assert!(para.contains_byte(49)); 115 + assert!(!para.contains_byte(50)); 116 + 117 + assert!(!para.contains_char(9)); 118 + assert!(para.contains_char(10)); 119 + assert!(para.contains_char(25)); 120 + assert!(!para.contains_char(50)); 121 + } 122 + }
+168
crates/weaver-editor-core/src/syntax.rs
··· 1 + //! Syntax span tracking for conditional visibility. 2 + //! 3 + //! Tracks markdown syntax characters (like `**`, `#`, `>`) so they can be 4 + //! shown/hidden based on cursor position (Obsidian-style editing). 5 + 6 + use std::ops::Range; 7 + 8 + use smol_str::SmolStr; 9 + 10 + /// Classification of markdown syntax characters. 11 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 12 + pub enum SyntaxType { 13 + /// Inline formatting: **, *, ~~, `, $, [, ], (, ) 14 + Inline, 15 + /// Block formatting: #, >, -, *, 1., ```, --- 16 + Block, 17 + } 18 + 19 + /// Information about a syntax span for conditional visibility. 20 + #[derive(Debug, Clone, PartialEq, Eq)] 21 + pub struct SyntaxSpanInfo { 22 + /// Unique identifier for this syntax span (e.g., "s0", "s1") 23 + pub syn_id: SmolStr, 24 + /// Source char range this syntax covers (just this marker) 25 + pub char_range: Range<usize>, 26 + /// Whether this is inline or block-level syntax 27 + pub syntax_type: SyntaxType, 28 + /// For paired inline syntax (**, *, etc), the full formatted region 29 + /// from opening marker through content to closing marker. 30 + /// When cursor is anywhere in this range, the syntax is visible. 31 + pub formatted_range: Option<Range<usize>>, 32 + } 33 + 34 + impl SyntaxSpanInfo { 35 + /// Adjust all position fields by a character delta. 36 + /// 37 + /// This adjusts both `char_range` and `formatted_range` (if present) together, 38 + /// ensuring they stay in sync. Use this instead of manually adjusting fields 39 + /// to avoid forgetting one. 40 + pub fn adjust_positions(&mut self, char_delta: isize) { 41 + self.char_range.start = (self.char_range.start as isize + char_delta) as usize; 42 + self.char_range.end = (self.char_range.end as isize + char_delta) as usize; 43 + if let Some(ref mut fr) = self.formatted_range { 44 + fr.start = (fr.start as isize + char_delta) as usize; 45 + fr.end = (fr.end as isize + char_delta) as usize; 46 + } 47 + } 48 + 49 + /// Check if cursor is within the visibility range for this syntax. 50 + /// 51 + /// Returns true if the cursor should cause this syntax to be visible. 52 + /// Uses `formatted_range` if present (for paired syntax like **bold**), 53 + /// otherwise uses `char_range` (for standalone syntax like # heading). 54 + pub fn cursor_in_range(&self, cursor_pos: usize) -> bool { 55 + let range = self.formatted_range.as_ref().unwrap_or(&self.char_range); 56 + cursor_pos >= range.start && cursor_pos <= range.end 57 + } 58 + } 59 + 60 + /// Classify syntax text as inline or block level. 61 + pub fn classify_syntax(text: &str) -> SyntaxType { 62 + let trimmed = text.trim_start(); 63 + 64 + // Check for block-level markers 65 + if trimmed.starts_with('#') 66 + || trimmed.starts_with('>') 67 + || trimmed.starts_with("```") 68 + || trimmed.starts_with("---") 69 + || (trimmed.starts_with('-') 70 + && trimmed 71 + .chars() 72 + .nth(1) 73 + .map(|c| c.is_whitespace()) 74 + .unwrap_or(false)) 75 + || (trimmed.starts_with('*') 76 + && trimmed 77 + .chars() 78 + .nth(1) 79 + .map(|c| c.is_whitespace()) 80 + .unwrap_or(false)) 81 + || trimmed 82 + .chars() 83 + .next() 84 + .map(|c| c.is_ascii_digit()) 85 + .unwrap_or(false) 86 + && trimmed.contains('.') 87 + { 88 + SyntaxType::Block 89 + } else { 90 + SyntaxType::Inline 91 + } 92 + } 93 + 94 + #[cfg(test)] 95 + mod tests { 96 + use smol_str::ToSmolStr; 97 + 98 + use super::*; 99 + 100 + #[test] 101 + fn test_classify_block_syntax() { 102 + assert_eq!(classify_syntax("# "), SyntaxType::Block); 103 + assert_eq!(classify_syntax("## "), SyntaxType::Block); 104 + assert_eq!(classify_syntax("> "), SyntaxType::Block); 105 + assert_eq!(classify_syntax("- "), SyntaxType::Block); 106 + assert_eq!(classify_syntax("* "), SyntaxType::Block); 107 + assert_eq!(classify_syntax("1. "), SyntaxType::Block); 108 + assert_eq!(classify_syntax("```"), SyntaxType::Block); 109 + assert_eq!(classify_syntax("---"), SyntaxType::Block); 110 + } 111 + 112 + #[test] 113 + fn test_classify_inline_syntax() { 114 + assert_eq!(classify_syntax("**"), SyntaxType::Inline); 115 + assert_eq!(classify_syntax("*"), SyntaxType::Inline); 116 + assert_eq!(classify_syntax("`"), SyntaxType::Inline); 117 + assert_eq!(classify_syntax("~~"), SyntaxType::Inline); 118 + assert_eq!(classify_syntax("["), SyntaxType::Inline); 119 + assert_eq!(classify_syntax("]("), SyntaxType::Inline); 120 + } 121 + 122 + #[test] 123 + fn test_adjust_positions() { 124 + let mut span = SyntaxSpanInfo { 125 + syn_id: "s0".to_smolstr(), 126 + char_range: 10..15, 127 + syntax_type: SyntaxType::Inline, 128 + formatted_range: Some(10..25), 129 + }; 130 + 131 + span.adjust_positions(5); 132 + assert_eq!(span.char_range, 15..20); 133 + assert_eq!(span.formatted_range, Some(15..30)); 134 + 135 + span.adjust_positions(-3); 136 + assert_eq!(span.char_range, 12..17); 137 + assert_eq!(span.formatted_range, Some(12..27)); 138 + } 139 + 140 + #[test] 141 + fn test_cursor_in_range() { 142 + // Paired syntax with formatted_range 143 + let span = SyntaxSpanInfo { 144 + syn_id: "s0".to_smolstr(), 145 + char_range: 0..2, // ** 146 + syntax_type: SyntaxType::Inline, 147 + formatted_range: Some(0..10), // **content** 148 + }; 149 + 150 + assert!(span.cursor_in_range(0)); 151 + assert!(span.cursor_in_range(5)); 152 + assert!(span.cursor_in_range(10)); 153 + assert!(!span.cursor_in_range(11)); 154 + 155 + // Unpaired syntax without formatted_range 156 + let span = SyntaxSpanInfo { 157 + syn_id: "s1".to_smolstr(), 158 + char_range: 0..2, // ## 159 + syntax_type: SyntaxType::Block, 160 + formatted_range: None, 161 + }; 162 + 163 + assert!(span.cursor_in_range(0)); 164 + assert!(span.cursor_in_range(1)); 165 + assert!(span.cursor_in_range(2)); 166 + assert!(!span.cursor_in_range(3)); 167 + } 168 + }
+203
crates/weaver-editor-core/src/text.rs
··· 1 + //! Text buffer abstraction for editor storage. 2 + //! 3 + //! The `TextBuffer` trait provides a common interface for text storage, 4 + //! allowing the editor to work with different backends (ropey for local, 5 + //! Loro for CRDT collaboration). 6 + 7 + use smol_str::{SmolStr, ToSmolStr}; 8 + use std::ops::Range; 9 + 10 + /// A text buffer that supports efficient editing and offset conversion. 11 + /// 12 + /// All offsets are in Unicode scalar values (chars), not bytes or UTF-16. 13 + pub trait TextBuffer: Default + Clone { 14 + /// Total length in bytes (UTF-8). 15 + fn len_bytes(&self) -> usize; 16 + 17 + /// Total length in chars (Unicode scalar values). 18 + fn len_chars(&self) -> usize; 19 + 20 + /// Check if empty. 21 + fn is_empty(&self) -> bool { 22 + self.len_chars() == 0 23 + } 24 + 25 + /// Insert text at char offset. 26 + fn insert(&mut self, char_offset: usize, text: &str); 27 + 28 + /// Delete char range. 29 + fn delete(&mut self, char_range: Range<usize>); 30 + 31 + /// Replace char range with text. 32 + fn replace(&mut self, char_range: Range<usize>, text: &str) { 33 + self.delete(char_range.clone()); 34 + self.insert(char_range.start, text); 35 + } 36 + 37 + /// Get a slice as SmolStr. Returns None if range is invalid. 38 + /// 39 + /// SmolStr is used for efficiency: strings ≤23 bytes are stored inline 40 + /// (no heap allocation), longer strings are Arc'd (cheap to clone). 41 + fn slice(&self, char_range: Range<usize>) -> Option<SmolStr>; 42 + 43 + /// Get character at offset. Returns None if out of bounds. 44 + fn char_at(&self, char_offset: usize) -> Option<char>; 45 + 46 + /// Convert entire buffer to String. 47 + fn to_string(&self) -> String; 48 + 49 + /// Convert char offset to byte offset. 50 + fn char_to_byte(&self, char_offset: usize) -> usize; 51 + 52 + /// Convert byte offset to char offset. 53 + fn byte_to_char(&self, byte_offset: usize) -> usize; 54 + } 55 + 56 + /// Ropey-backed text buffer for local editing. 57 + /// 58 + /// Provides O(log n) editing operations and offset conversions. 59 + #[derive(Clone, Default)] 60 + pub struct EditorRope { 61 + rope: ropey::Rope, 62 + } 63 + 64 + impl EditorRope { 65 + /// Create a new empty rope. 66 + pub fn new() -> Self { 67 + Self::default() 68 + } 69 + 70 + /// Create from string. 71 + pub fn from_str(s: &str) -> Self { 72 + Self { 73 + rope: ropey::Rope::from_str(s), 74 + } 75 + } 76 + 77 + /// Get a reference to the underlying rope (for advanced operations). 78 + pub fn rope(&self) -> &ropey::Rope { 79 + &self.rope 80 + } 81 + 82 + /// Get a rope slice for zero-copy iteration over chunks. 83 + /// 84 + /// Use this when you need to iterate over the text without allocating, 85 + /// e.g., for hashing or character-by-character processing. 86 + pub fn rope_slice(&self, char_range: Range<usize>) -> Option<ropey::RopeSlice<'_>> { 87 + if char_range.end > self.rope.len_chars() { 88 + return None; 89 + } 90 + Some(self.rope.slice(char_range)) 91 + } 92 + } 93 + 94 + impl TextBuffer for EditorRope { 95 + fn len_bytes(&self) -> usize { 96 + self.rope.len_bytes() 97 + } 98 + 99 + fn len_chars(&self) -> usize { 100 + self.rope.len_chars() 101 + } 102 + 103 + fn insert(&mut self, char_offset: usize, text: &str) { 104 + self.rope.insert(char_offset, text); 105 + } 106 + 107 + fn delete(&mut self, char_range: Range<usize>) { 108 + self.rope.remove(char_range); 109 + } 110 + 111 + fn slice(&self, char_range: Range<usize>) -> Option<SmolStr> { 112 + if char_range.end > self.len_chars() { 113 + return None; 114 + } 115 + Some(self.rope.slice(char_range).to_smolstr()) 116 + } 117 + 118 + fn char_at(&self, char_offset: usize) -> Option<char> { 119 + if char_offset >= self.len_chars() { 120 + return None; 121 + } 122 + Some(self.rope.char(char_offset)) 123 + } 124 + 125 + fn to_string(&self) -> String { 126 + self.rope.to_string() 127 + } 128 + 129 + fn char_to_byte(&self, char_offset: usize) -> usize { 130 + self.rope.char_to_byte(char_offset) 131 + } 132 + 133 + fn byte_to_char(&self, byte_offset: usize) -> usize { 134 + self.rope.byte_to_char(byte_offset) 135 + } 136 + } 137 + 138 + impl From<&str> for EditorRope { 139 + fn from(s: &str) -> Self { 140 + Self::from_str(s) 141 + } 142 + } 143 + 144 + impl From<String> for EditorRope { 145 + fn from(s: String) -> Self { 146 + Self::from_str(&s) 147 + } 148 + } 149 + 150 + #[cfg(test)] 151 + mod tests { 152 + use super::*; 153 + 154 + #[test] 155 + fn test_basic_operations() { 156 + let mut rope = EditorRope::from_str("hello world"); 157 + assert_eq!(rope.len_chars(), 11); 158 + assert_eq!(rope.to_string(), "hello world"); 159 + 160 + rope.insert(5, " beautiful"); 161 + assert_eq!(rope.to_string(), "hello beautiful world"); 162 + 163 + // " beautiful" is 10 chars at positions 5..15 164 + rope.delete(5..15); 165 + assert_eq!(rope.to_string(), "hello world"); 166 + } 167 + 168 + #[test] 169 + fn test_char_at() { 170 + let rope = EditorRope::from_str("hello"); 171 + assert_eq!(rope.char_at(0), Some('h')); 172 + assert_eq!(rope.char_at(4), Some('o')); 173 + assert_eq!(rope.char_at(5), None); 174 + } 175 + 176 + #[test] 177 + fn test_slice() { 178 + let rope = EditorRope::from_str("hello world"); 179 + assert_eq!(rope.slice(0..5).as_deref(), Some("hello")); 180 + assert_eq!(rope.slice(6..11).as_deref(), Some("world")); 181 + assert_eq!(rope.slice(0..100), None); 182 + } 183 + 184 + #[test] 185 + fn test_offset_conversion() { 186 + // "hello 🌍" - emoji is 4 bytes, 1 char 187 + let rope = EditorRope::from_str("hello 🌍"); 188 + assert_eq!(rope.len_chars(), 7); // h e l l o 🌍 189 + assert_eq!(rope.len_bytes(), 10); // 6 + 4 190 + 191 + assert_eq!(rope.char_to_byte(6), 6); // before emoji 192 + assert_eq!(rope.char_to_byte(7), 10); // after emoji 193 + assert_eq!(rope.byte_to_char(6), 6); 194 + assert_eq!(rope.byte_to_char(10), 7); 195 + } 196 + 197 + #[test] 198 + fn test_replace() { 199 + let mut rope = EditorRope::from_str("hello world"); 200 + rope.replace(6..11, "rust"); 201 + assert_eq!(rope.to_string(), "hello rust"); 202 + } 203 + }
+319
crates/weaver-editor-core/src/types.rs
··· 1 + //! Core editor types: cursor, selection, composition, and edit tracking. 2 + //! 3 + //! These types are framework-agnostic and can be used with any text buffer implementation. 4 + 5 + use std::ops::Range; 6 + use web_time::Instant; 7 + 8 + /// Cursor state including position and affinity. 9 + #[derive(Clone, Debug, Copy, PartialEq, Eq)] 10 + pub struct CursorState { 11 + /// Character offset in text (NOT byte offset!) 12 + pub offset: usize, 13 + 14 + /// Prefer left/right when at boundary (for vertical cursor movement) 15 + pub affinity: Affinity, 16 + } 17 + 18 + impl Default for CursorState { 19 + fn default() -> Self { 20 + Self { 21 + offset: 0, 22 + affinity: Affinity::Before, 23 + } 24 + } 25 + } 26 + 27 + impl CursorState { 28 + /// Create a new cursor at the given offset. 29 + pub fn new(offset: usize) -> Self { 30 + Self { 31 + offset, 32 + affinity: Affinity::Before, 33 + } 34 + } 35 + 36 + /// Create a cursor with specific affinity. 37 + pub fn with_affinity(offset: usize, affinity: Affinity) -> Self { 38 + Self { offset, affinity } 39 + } 40 + } 41 + 42 + /// Cursor affinity for vertical movement. 43 + /// 44 + /// When navigating vertically, the cursor needs to know which side of a line 45 + /// break it prefers. `Before` means stick to the end of the previous line, 46 + /// `After` means stick to the start of the next line. 47 + #[derive(Clone, Debug, Copy, PartialEq, Eq, Default)] 48 + pub enum Affinity { 49 + #[default] 50 + Before, 51 + After, 52 + } 53 + 54 + /// Text selection with anchor and head positions. 55 + /// 56 + /// The anchor is where the selection started, the head is where the cursor is now. 57 + /// They may be in any order - use `start()` and `end()` for ordered bounds. 58 + #[derive(Clone, Debug, Copy, PartialEq, Eq)] 59 + pub struct Selection { 60 + /// Where selection started 61 + pub anchor: usize, 62 + /// Where cursor is now 63 + pub head: usize, 64 + } 65 + 66 + impl Selection { 67 + /// Create a new selection. 68 + pub fn new(anchor: usize, head: usize) -> Self { 69 + Self { anchor, head } 70 + } 71 + 72 + /// Create a collapsed selection (cursor position). 73 + pub fn collapsed(offset: usize) -> Self { 74 + Self { 75 + anchor: offset, 76 + head: offset, 77 + } 78 + } 79 + 80 + /// Get the start (lower bound) of the selection. 81 + pub fn start(&self) -> usize { 82 + self.anchor.min(self.head) 83 + } 84 + 85 + /// Get the end (upper bound) of the selection. 86 + pub fn end(&self) -> usize { 87 + self.anchor.max(self.head) 88 + } 89 + 90 + /// Check if the selection is collapsed (empty, cursor only). 91 + pub fn is_collapsed(&self) -> bool { 92 + self.anchor == self.head 93 + } 94 + 95 + /// Check if an offset is within the selection. 96 + pub fn contains(&self, offset: usize) -> bool { 97 + offset >= self.start() && offset < self.end() 98 + } 99 + 100 + /// Get the selection length. 101 + pub fn len(&self) -> usize { 102 + self.end() - self.start() 103 + } 104 + 105 + /// Check if empty (same as is_collapsed). 106 + pub fn is_empty(&self) -> bool { 107 + self.is_collapsed() 108 + } 109 + 110 + /// Convert to a Range<usize> (ordered). 111 + pub fn to_range(&self) -> Range<usize> { 112 + self.start()..self.end() 113 + } 114 + 115 + /// Check if the selection is backwards (head before anchor). 116 + pub fn is_backwards(&self) -> bool { 117 + self.head < self.anchor 118 + } 119 + } 120 + 121 + /// IME composition state (for international text input). 122 + /// 123 + /// During IME composition, the user is building up a string of characters 124 + /// that hasn't been committed yet. This tracks where that composition 125 + /// started and what text is currently being composed. 126 + #[derive(Clone, Debug, PartialEq, Eq)] 127 + pub struct CompositionState { 128 + /// Character offset where composition started 129 + pub start_offset: usize, 130 + /// Current composition text (uncommitted) 131 + pub text: String, 132 + } 133 + 134 + impl CompositionState { 135 + /// Create a new composition state. 136 + pub fn new(start_offset: usize, text: String) -> Self { 137 + Self { start_offset, text } 138 + } 139 + 140 + /// Get the end offset of the composition. 141 + pub fn end_offset(&self) -> usize { 142 + self.start_offset + self.text.chars().count() 143 + } 144 + 145 + /// Check if an offset is within the composition. 146 + pub fn contains(&self, offset: usize) -> bool { 147 + offset >= self.start_offset && offset < self.end_offset() 148 + } 149 + } 150 + 151 + /// Information about the most recent edit, used for incremental rendering optimization. 152 + /// 153 + /// This tracks enough information to determine which paragraphs need re-rendering 154 + /// after an edit, enabling efficient incremental updates instead of full re-renders. 155 + #[derive(Clone, Debug)] 156 + pub struct EditInfo { 157 + /// Character offset where the edit occurred 158 + pub edit_char_pos: usize, 159 + /// Number of characters inserted 160 + pub inserted_len: usize, 161 + /// Number of characters deleted 162 + pub deleted_len: usize, 163 + /// Whether the edit contains a newline (boundary-affecting) 164 + pub contains_newline: bool, 165 + /// Whether the edit is in the block-syntax zone of a line (first ~6 chars). 166 + /// Edits here could affect block-level syntax like headings, lists, code fences. 167 + pub in_block_syntax_zone: bool, 168 + /// Document length (in chars) after this edit was applied. 169 + /// Used to detect stale edit info - if current doc length doesn't match, 170 + /// the edit info is from a previous render cycle and shouldn't be used. 171 + pub doc_len_after: usize, 172 + /// When this edit occurred. Used for idle detection in collaborative sync. 173 + pub timestamp: Instant, 174 + } 175 + 176 + impl PartialEq for EditInfo { 177 + fn eq(&self, other: &Self) -> bool { 178 + // Compare all fields except timestamp (not meaningful for equality) 179 + self.edit_char_pos == other.edit_char_pos 180 + && self.inserted_len == other.inserted_len 181 + && self.deleted_len == other.deleted_len 182 + && self.contains_newline == other.contains_newline 183 + && self.in_block_syntax_zone == other.in_block_syntax_zone 184 + && self.doc_len_after == other.doc_len_after 185 + } 186 + } 187 + 188 + impl EditInfo { 189 + /// Check if this edit info is stale (doc has changed since this edit). 190 + pub fn is_stale(&self, current_doc_len: usize) -> bool { 191 + self.doc_len_after != current_doc_len 192 + } 193 + 194 + /// Check if this edit might affect paragraph boundaries. 195 + pub fn affects_boundaries(&self) -> bool { 196 + self.contains_newline || self.in_block_syntax_zone 197 + } 198 + 199 + /// Get the range that was affected by this edit. 200 + /// 201 + /// For insertions: the range of inserted text. 202 + /// For deletions: an empty range at the deletion point. 203 + /// For replacements: the range of inserted text. 204 + pub fn affected_range(&self) -> Range<usize> { 205 + self.edit_char_pos..self.edit_char_pos + self.inserted_len 206 + } 207 + } 208 + 209 + /// Max distance from line start where block syntax can appear. 210 + /// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) 211 + pub const BLOCK_SYNTAX_ZONE: usize = 6; 212 + 213 + #[cfg(test)] 214 + mod tests { 215 + use super::*; 216 + 217 + #[test] 218 + fn test_selection_bounds() { 219 + // Forward selection 220 + let sel = Selection::new(5, 10); 221 + assert_eq!(sel.start(), 5); 222 + assert_eq!(sel.end(), 10); 223 + assert!(!sel.is_backwards()); 224 + 225 + // Backward selection 226 + let sel = Selection::new(10, 5); 227 + assert_eq!(sel.start(), 5); 228 + assert_eq!(sel.end(), 10); 229 + assert!(sel.is_backwards()); 230 + } 231 + 232 + #[test] 233 + fn test_selection_collapsed() { 234 + let sel = Selection::collapsed(7); 235 + assert!(sel.is_collapsed()); 236 + assert!(sel.is_empty()); 237 + assert_eq!(sel.len(), 0); 238 + assert_eq!(sel.start(), 7); 239 + assert_eq!(sel.end(), 7); 240 + } 241 + 242 + #[test] 243 + fn test_selection_contains() { 244 + let sel = Selection::new(5, 10); 245 + assert!(!sel.contains(4)); 246 + assert!(sel.contains(5)); 247 + assert!(sel.contains(7)); 248 + assert!(sel.contains(9)); 249 + assert!(!sel.contains(10)); // end is exclusive 250 + } 251 + 252 + #[test] 253 + fn test_selection_to_range() { 254 + let sel = Selection::new(10, 5); 255 + assert_eq!(sel.to_range(), 5..10); 256 + } 257 + 258 + #[test] 259 + fn test_composition_contains() { 260 + let comp = CompositionState::new(10, "你好".to_string()); 261 + assert_eq!(comp.end_offset(), 12); // 2 chars 262 + assert!(!comp.contains(9)); 263 + assert!(comp.contains(10)); 264 + assert!(comp.contains(11)); 265 + assert!(!comp.contains(12)); // end is exclusive 266 + } 267 + 268 + #[test] 269 + fn test_edit_info_stale() { 270 + let edit = EditInfo { 271 + edit_char_pos: 5, 272 + inserted_len: 3, 273 + deleted_len: 0, 274 + contains_newline: false, 275 + in_block_syntax_zone: false, 276 + doc_len_after: 100, 277 + timestamp: Instant::now(), 278 + }; 279 + 280 + assert!(!edit.is_stale(100)); 281 + assert!(edit.is_stale(101)); 282 + } 283 + 284 + #[test] 285 + fn test_edit_info_affects_boundaries() { 286 + let edit = EditInfo { 287 + edit_char_pos: 0, 288 + inserted_len: 1, 289 + deleted_len: 0, 290 + contains_newline: false, 291 + in_block_syntax_zone: true, 292 + doc_len_after: 100, 293 + timestamp: Instant::now(), 294 + }; 295 + assert!(edit.affects_boundaries()); 296 + 297 + let edit = EditInfo { 298 + edit_char_pos: 50, 299 + inserted_len: 1, 300 + deleted_len: 0, 301 + contains_newline: true, 302 + in_block_syntax_zone: false, 303 + doc_len_after: 100, 304 + timestamp: Instant::now(), 305 + }; 306 + assert!(edit.affects_boundaries()); 307 + 308 + let edit = EditInfo { 309 + edit_char_pos: 50, 310 + inserted_len: 1, 311 + deleted_len: 0, 312 + contains_newline: false, 313 + in_block_syntax_zone: false, 314 + doc_len_after: 100, 315 + timestamp: Instant::now(), 316 + }; 317 + assert!(!edit.affects_boundaries()); 318 + } 319 + }
+396
crates/weaver-editor-core/src/visibility.rs
··· 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 + 6 + use smol_str::SmolStr; 7 + 8 + use crate::paragraph::ParagraphRender; 9 + use crate::syntax::{SyntaxSpanInfo, SyntaxType}; 10 + use crate::types::Selection; 11 + use std::collections::HashSet; 12 + use std::ops::Range; 13 + 14 + /// Determines which syntax spans should be visible based on cursor/selection. 15 + #[derive(Debug, Clone, Default)] 16 + pub struct VisibilityState { 17 + /// Set of syn_ids that should be visible 18 + pub visible_span_ids: HashSet<SmolStr>, 19 + } 20 + 21 + impl 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. 124 + fn 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. 137 + fn 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. 158 + fn 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. 167 + fn 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)] 173 + mod 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 + }
+32 -2
docker-compose.yml
··· 1 1 services: 2 + # ClickHouse - analytics database (internal only, no host ports exposed) 3 + clickhouse: 4 + image: clickhouse/clickhouse-server:25.11 5 + container_name: weaver-clickhouse 6 + # No ports exposed to host - only accessible via docker network 7 + ports: 8 + - "8123:8123" 9 + - "9000:9000" 10 + volumes: 11 + - ~/data/clickhouse:/var/lib/clickhouse 12 + - ~/data/clickhouse-logs:/var/log/clickhouse-server 13 + - ~/data/clickhouse-config:/etc/clickhouse-server/config.d 14 + environment: 15 + CLICKHOUSE_DB: ${CLICKHOUSE_DATABASE:-weaver} 16 + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} 17 + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} 18 + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 19 + ulimits: 20 + nofile: 21 + soft: 262144 22 + hard: 262144 23 + healthcheck: 24 + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"] 25 + interval: 10s 26 + timeout: 5s 27 + retries: 3 28 + restart: unless-stopped 29 + 2 30 # Docker registry for local image hosting 3 31 registry: 4 32 image: registry:2 ··· 45 73 - index_data:/app/data 46 74 environment: 47 75 RUST_LOG: info,weaver_index=debug,hyper_util::client::legacy::pool=info 48 - CLICKHOUSE_URL: ${CLICKHOUSE_URL} 76 + CLICKHOUSE_URL: http://clickhouse:8123 49 77 CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-weaver} 50 - CLICKHOUSE_USER: ${CLICKHOUSE_USER} 78 + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} 51 79 CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} 52 80 INDEXER_SOURCE: tap 53 81 TAP_URL: ws://tap:2480/channel ··· 56 84 INDEXER_COLLECTIONS: "sh.weaver.*,app.bsky.actor.profile,sh.tangled.*,pub.leaflet.*,net.anisota.*,place.stream.*" 57 85 depends_on: 58 86 tap: 87 + condition: service_healthy 88 + clickhouse: 59 89 condition: service_healthy 60 90 healthcheck: 61 91 test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3000/xrpc/_health"]