···11+//! weaver-editor-core: Pure Rust editor logic without framework dependencies.
22+//!
33+//! This crate provides:
44+//! - `TextBuffer` trait for text storage abstraction
55+//! - `EditorRope` - ropey-backed implementation
66+//! - `EditorDocument<T>` - generic document with undo support
77+//! - Rendering, actions, formatting - all generic over TextBuffer
88+99+pub mod offset_map;
1010+pub mod paragraph;
1111+pub mod syntax;
1212+pub mod text;
1313+pub mod types;
1414+pub mod visibility;
1515+1616+pub use offset_map::{
1717+ OffsetMapping, RenderResult, SnapDirection, SnappedPosition, find_mapping_for_byte,
1818+ find_mapping_for_char, find_nearest_valid_position, is_valid_cursor_position,
1919+};
2020+pub use paragraph::{ParagraphRender, hash_source, make_paragraph_id};
2121+pub use smol_str::SmolStr;
2222+pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
2323+pub use text::{EditorRope, TextBuffer};
2424+pub use types::{
2525+ Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE,
2626+};
2727+pub use visibility::VisibilityState;
+524
crates/weaver-editor-core/src/offset_map.rs
···11+//! Offset mapping between source text and rendered DOM.
22+//!
33+//! When rendering markdown to HTML, some characters disappear (table pipes)
44+//! and content gets split across nodes (syntax highlighting). Offset maps
55+//! track how source byte positions map to DOM node positions.
66+77+use std::ops::Range;
88+99+/// Result of rendering markdown with offset tracking.
1010+#[derive(Debug, Clone, PartialEq)]
1111+pub struct RenderResult {
1212+ /// Rendered HTML string
1313+ pub html: String,
1414+1515+ /// Mappings from source bytes to DOM positions
1616+ pub offset_map: Vec<OffsetMapping>,
1717+}
1818+1919+/// Maps a source range to a position in the rendered DOM.
2020+///
2121+/// # Example
2222+///
2323+/// Source: `| foo | bar |`
2424+/// Bytes: 0 2-5 7-10 12
2525+/// Chars: 0 2-5 7-10 12 (ASCII, so same)
2626+///
2727+/// Rendered:
2828+/// ```html
2929+/// <table id="t0">
3030+/// <tr><td id="t0-c0">foo</td><td id="t0-c1">bar</td></tr>
3131+/// </table>
3232+/// ```
3333+///
3434+/// Mappings:
3535+/// - `{ byte_range: 0..2, char_range: 0..2, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 0 }` - "| " invisible
3636+/// - `{ byte_range: 2..5, char_range: 2..5, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 3 }` - "foo" visible
3737+/// - `{ byte_range: 5..7, char_range: 5..7, node_id: "t0-c0", char_offset_in_node: 3, utf16_len: 0 }` - " |" invisible
3838+/// - etc.
3939+#[derive(Debug, Clone, PartialEq)]
4040+pub struct OffsetMapping {
4141+ /// Source byte range (UTF-8 bytes, from parser)
4242+ pub byte_range: Range<usize>,
4343+4444+ /// Source char range (Unicode scalar values, for rope indexing)
4545+ pub char_range: Range<usize>,
4646+4747+ /// DOM node ID containing this content
4848+ /// For invisible content, this is the nearest visible container
4949+ pub node_id: String,
5050+5151+ /// Position within the node
5252+ /// - If child_index is Some: cursor at that child index in the element
5353+ /// - If child_index is None: UTF-16 offset in text content
5454+ pub char_offset_in_node: usize,
5555+5656+ /// If Some, position cursor at this child index in the element (not in text)
5757+ /// Used for positions after <br /> or at empty lines
5858+ pub child_index: Option<usize>,
5959+6060+ /// Length of this mapping in UTF-16 chars in DOM
6161+ /// If 0, these source bytes aren't rendered (table pipes, etc)
6262+ pub utf16_len: usize,
6363+}
6464+6565+impl OffsetMapping {
6666+ /// Check if this mapping contains the given byte offset
6767+ pub fn contains_byte(&self, byte_offset: usize) -> bool {
6868+ self.byte_range.contains(&byte_offset)
6969+ }
7070+7171+ /// Check if this mapping contains the given char offset
7272+ pub fn contains_char(&self, char_offset: usize) -> bool {
7373+ self.char_range.contains(&char_offset)
7474+ }
7575+7676+ /// Check if this mapping represents invisible content
7777+ pub fn is_invisible(&self) -> bool {
7878+ self.utf16_len == 0
7979+ }
8080+}
8181+8282+/// Find the offset mapping containing the given byte offset.
8383+///
8484+/// Returns the mapping and whether the cursor should snap to the next
8585+/// visible position (for invisible content).
8686+pub fn find_mapping_for_byte(
8787+ offset_map: &[OffsetMapping],
8888+ byte_offset: usize,
8989+) -> Option<(&OffsetMapping, bool)> {
9090+ // Binary search for the mapping
9191+ // Note: We allow cursor at the end boundary of a mapping (cursor after text)
9292+ let idx = offset_map
9393+ .binary_search_by(|mapping| {
9494+ if mapping.byte_range.end < byte_offset {
9595+ std::cmp::Ordering::Less
9696+ } else if mapping.byte_range.start > byte_offset {
9797+ std::cmp::Ordering::Greater
9898+ } else {
9999+ std::cmp::Ordering::Equal
100100+ }
101101+ })
102102+ .ok()?;
103103+104104+ let mapping = &offset_map[idx];
105105+ let should_snap = mapping.is_invisible();
106106+107107+ Some((mapping, should_snap))
108108+}
109109+110110+/// Find the offset mapping containing the given char offset.
111111+///
112112+/// This is the primary lookup method for cursor restoration, since
113113+/// cursor positions are tracked as char offsets in the rope.
114114+///
115115+/// Returns the mapping and whether the cursor should snap to the next
116116+/// visible position (for invisible content).
117117+pub fn find_mapping_for_char(
118118+ offset_map: &[OffsetMapping],
119119+ char_offset: usize,
120120+) -> Option<(&OffsetMapping, bool)> {
121121+ // Binary search for the mapping
122122+ // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9.
123123+ // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20),
124124+ // prefer the NEXT mapping so cursor goes "down" to new content.
125125+ let result = offset_map.binary_search_by(|mapping| {
126126+ if mapping.char_range.end <= char_offset {
127127+ // Cursor is at or after end of this mapping - look forward
128128+ std::cmp::Ordering::Less
129129+ } else if mapping.char_range.start > char_offset {
130130+ // Cursor is before this mapping
131131+ std::cmp::Ordering::Greater
132132+ } else {
133133+ // Cursor is within [start, end)
134134+ std::cmp::Ordering::Equal
135135+ }
136136+ });
137137+138138+ let mapping = match result {
139139+ Ok(idx) => &offset_map[idx],
140140+ Err(idx) => {
141141+ // No exact match - cursor is at boundary between mappings (or past end)
142142+ // If cursor is exactly at end of previous mapping, return that mapping
143143+ // This handles cursor at end of document or end of last mapping
144144+ if idx > 0 && offset_map[idx - 1].char_range.end == char_offset {
145145+ &offset_map[idx - 1]
146146+ } else {
147147+ return None;
148148+ }
149149+ }
150150+ };
151151+152152+ let should_snap = mapping.is_invisible();
153153+ Some((mapping, should_snap))
154154+}
155155+156156+/// Direction hint for cursor snapping.
157157+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158158+pub enum SnapDirection {
159159+ Backward,
160160+ Forward,
161161+}
162162+163163+/// Result of finding a valid cursor position.
164164+#[derive(Debug, Clone)]
165165+pub struct SnappedPosition<'a> {
166166+ pub mapping: &'a OffsetMapping,
167167+ pub offset_in_mapping: usize,
168168+ pub snapped: Option<SnapDirection>,
169169+}
170170+171171+impl SnappedPosition<'_> {
172172+ /// Get the absolute char offset for this position.
173173+ pub fn char_offset(&self) -> usize {
174174+ self.mapping.char_range.start + self.offset_in_mapping
175175+ }
176176+}
177177+178178+/// Find the nearest valid cursor position to a char offset.
179179+///
180180+/// A valid position is one that maps to visible content (utf16_len > 0).
181181+/// If the position is already valid, returns it directly. Otherwise,
182182+/// searches in the preferred direction first, falling back to the other
183183+/// direction if needed.
184184+pub fn find_nearest_valid_position(
185185+ offset_map: &[OffsetMapping],
186186+ char_offset: usize,
187187+ preferred_direction: Option<SnapDirection>,
188188+) -> Option<SnappedPosition<'_>> {
189189+ if offset_map.is_empty() {
190190+ return None;
191191+ }
192192+193193+ // Try exact match first
194194+ if let Some((mapping, should_snap)) = find_mapping_for_char(offset_map, char_offset) {
195195+ if !should_snap {
196196+ // Position is valid, return it directly
197197+ let offset_in_mapping = char_offset.saturating_sub(mapping.char_range.start);
198198+ return Some(SnappedPosition {
199199+ mapping,
200200+ offset_in_mapping,
201201+ snapped: None,
202202+ });
203203+ }
204204+ }
205205+206206+ // Position is invalid or not found - search for nearest valid
207207+ let search_order = match preferred_direction {
208208+ Some(SnapDirection::Backward) => [SnapDirection::Backward, SnapDirection::Forward],
209209+ Some(SnapDirection::Forward) | None => [SnapDirection::Forward, SnapDirection::Backward],
210210+ };
211211+212212+ for direction in search_order {
213213+ if let Some(pos) = find_valid_in_direction(offset_map, char_offset, direction) {
214214+ return Some(pos);
215215+ }
216216+ }
217217+218218+ None
219219+}
220220+221221+/// Search for a valid position in a specific direction.
222222+fn find_valid_in_direction(
223223+ offset_map: &[OffsetMapping],
224224+ char_offset: usize,
225225+ direction: SnapDirection,
226226+) -> Option<SnappedPosition<'_>> {
227227+ match direction {
228228+ SnapDirection::Forward => {
229229+ // Find first visible mapping at or after char_offset
230230+ for mapping in offset_map {
231231+ if mapping.char_range.start >= char_offset && !mapping.is_invisible() {
232232+ return Some(SnappedPosition {
233233+ mapping,
234234+ offset_in_mapping: 0,
235235+ snapped: Some(SnapDirection::Forward),
236236+ });
237237+ }
238238+ // Also check if char_offset falls within this visible mapping
239239+ if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() {
240240+ let offset_in_mapping = char_offset - mapping.char_range.start;
241241+ return Some(SnappedPosition {
242242+ mapping,
243243+ offset_in_mapping,
244244+ snapped: Some(SnapDirection::Forward),
245245+ });
246246+ }
247247+ }
248248+ None
249249+ }
250250+ SnapDirection::Backward => {
251251+ // Find last visible mapping at or before char_offset
252252+ for mapping in offset_map.iter().rev() {
253253+ if mapping.char_range.end <= char_offset && !mapping.is_invisible() {
254254+ // Snap to end of this mapping
255255+ let offset_in_mapping = mapping.char_range.len();
256256+ return Some(SnappedPosition {
257257+ mapping,
258258+ offset_in_mapping,
259259+ snapped: Some(SnapDirection::Backward),
260260+ });
261261+ }
262262+ // Also check if char_offset falls within this visible mapping
263263+ if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() {
264264+ let offset_in_mapping = char_offset - mapping.char_range.start;
265265+ return Some(SnappedPosition {
266266+ mapping,
267267+ offset_in_mapping,
268268+ snapped: Some(SnapDirection::Backward),
269269+ });
270270+ }
271271+ }
272272+ None
273273+ }
274274+ }
275275+}
276276+277277+/// Check if a char offset is at a valid (non-invisible) cursor position.
278278+pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool {
279279+ find_mapping_for_char(offset_map, char_offset)
280280+ .map(|(m, should_snap)| !should_snap && m.utf16_len > 0)
281281+ .unwrap_or(false)
282282+}
283283+284284+#[cfg(test)]
285285+mod tests {
286286+ use super::*;
287287+288288+ #[test]
289289+ fn test_find_mapping_by_byte() {
290290+ let mappings = vec![
291291+ OffsetMapping {
292292+ byte_range: 0..2,
293293+ char_range: 0..2,
294294+ node_id: "n0".to_string(),
295295+ char_offset_in_node: 0,
296296+ child_index: None,
297297+ utf16_len: 0, // invisible
298298+ },
299299+ OffsetMapping {
300300+ byte_range: 2..5,
301301+ char_range: 2..5,
302302+ node_id: "n0".to_string(),
303303+ char_offset_in_node: 0,
304304+ child_index: None,
305305+ utf16_len: 3,
306306+ },
307307+ OffsetMapping {
308308+ byte_range: 5..7,
309309+ char_range: 5..7,
310310+ node_id: "n0".to_string(),
311311+ char_offset_in_node: 3,
312312+ child_index: None,
313313+ utf16_len: 0, // invisible
314314+ },
315315+ ];
316316+317317+ // Byte 0 (invisible)
318318+ let (mapping, should_snap) = find_mapping_for_byte(&mappings, 0).unwrap();
319319+ assert_eq!(mapping.byte_range, 0..2);
320320+ assert!(should_snap);
321321+322322+ // Byte 3 (visible)
323323+ let (mapping, should_snap) = find_mapping_for_byte(&mappings, 3).unwrap();
324324+ assert_eq!(mapping.byte_range, 2..5);
325325+ assert!(!should_snap);
326326+327327+ // Byte 6 (invisible)
328328+ let (mapping, should_snap) = find_mapping_for_byte(&mappings, 6).unwrap();
329329+ assert_eq!(mapping.byte_range, 5..7);
330330+ assert!(should_snap);
331331+ }
332332+333333+ #[test]
334334+ fn test_find_mapping_by_char() {
335335+ let mappings = vec![
336336+ OffsetMapping {
337337+ byte_range: 0..2,
338338+ char_range: 0..2,
339339+ node_id: "n0".to_string(),
340340+ char_offset_in_node: 0,
341341+ child_index: None,
342342+ utf16_len: 0, // invisible
343343+ },
344344+ OffsetMapping {
345345+ byte_range: 2..5,
346346+ char_range: 2..5,
347347+ node_id: "n0".to_string(),
348348+ char_offset_in_node: 0,
349349+ child_index: None,
350350+ utf16_len: 3,
351351+ },
352352+ OffsetMapping {
353353+ byte_range: 5..7,
354354+ char_range: 5..7,
355355+ node_id: "n0".to_string(),
356356+ char_offset_in_node: 3,
357357+ child_index: None,
358358+ utf16_len: 0, // invisible
359359+ },
360360+ ];
361361+362362+ // Char 0 (invisible)
363363+ let (mapping, should_snap) = find_mapping_for_char(&mappings, 0).unwrap();
364364+ assert_eq!(mapping.char_range, 0..2);
365365+ assert!(should_snap);
366366+367367+ // Char 3 (visible)
368368+ let (mapping, should_snap) = find_mapping_for_char(&mappings, 3).unwrap();
369369+ assert_eq!(mapping.char_range, 2..5);
370370+ assert!(!should_snap);
371371+372372+ // Char 6 (invisible)
373373+ let (mapping, should_snap) = find_mapping_for_char(&mappings, 6).unwrap();
374374+ assert_eq!(mapping.char_range, 5..7);
375375+ assert!(should_snap);
376376+ }
377377+378378+ #[test]
379379+ fn test_contains_byte() {
380380+ let mapping = OffsetMapping {
381381+ byte_range: 10..20,
382382+ char_range: 10..20,
383383+ node_id: "test".to_string(),
384384+ char_offset_in_node: 0,
385385+ child_index: None,
386386+ utf16_len: 5,
387387+ };
388388+389389+ assert!(!mapping.contains_byte(9));
390390+ assert!(mapping.contains_byte(10));
391391+ assert!(mapping.contains_byte(15));
392392+ assert!(mapping.contains_byte(19));
393393+ assert!(!mapping.contains_byte(20));
394394+ }
395395+396396+ #[test]
397397+ fn test_contains_char() {
398398+ let mapping = OffsetMapping {
399399+ byte_range: 10..20,
400400+ char_range: 8..15, // emoji example: fewer chars than bytes
401401+ node_id: "test".to_string(),
402402+ char_offset_in_node: 0,
403403+ child_index: None,
404404+ utf16_len: 5,
405405+ };
406406+407407+ assert!(!mapping.contains_char(7));
408408+ assert!(mapping.contains_char(8));
409409+ assert!(mapping.contains_char(12));
410410+ assert!(mapping.contains_char(14));
411411+ assert!(!mapping.contains_char(15));
412412+ }
413413+414414+ fn make_test_mappings() -> Vec<OffsetMapping> {
415415+ vec"
439439+ },
440440+ OffsetMapping {
441441+ byte_range: 15..20,
442442+ char_range: 15..20,
443443+ node_id: "n0".to_string(),
444444+ char_offset_in_node: 3,
445445+ child_index: None,
446446+ utf16_len: 5, // visible: " text"
447447+ },
448448+ ]
449449+ }
450450+451451+ #[test]
452452+ fn test_find_nearest_valid_position_exact_match() {
453453+ let mappings = make_test_mappings();
454454+455455+ // Position 3 is in visible mapping (2..5)
456456+ let pos = find_nearest_valid_position(&mappings, 3, None).unwrap();
457457+ assert_eq!(pos.char_offset(), 3);
458458+ assert!(pos.snapped.is_none());
459459+ }
460460+461461+ #[test]
462462+ fn test_find_nearest_valid_position_snap_forward() {
463463+ let mappings = make_test_mappings();
464464+465465+ // Position 0 is invisible, should snap forward to 2
466466+ let pos = find_nearest_valid_position(&mappings, 0, Some(SnapDirection::Forward)).unwrap();
467467+ assert_eq!(pos.char_offset(), 2);
468468+ assert_eq!(pos.snapped, Some(SnapDirection::Forward));
469469+ }
470470+471471+ #[test]
472472+ fn test_find_nearest_valid_position_snap_backward() {
473473+ let mappings = make_test_mappings();
474474+475475+ // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5)
476476+ let pos =
477477+ find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap();
478478+ assert_eq!(pos.char_offset(), 5); // end of "alt" mapping
479479+ assert_eq!(pos.snapped, Some(SnapDirection::Backward));
480480+ }
481481+482482+ #[test]
483483+ fn test_find_nearest_valid_position_default_forward() {
484484+ let mappings = make_test_mappings();
485485+486486+ // Position 0 is invisible, None direction defaults to forward
487487+ let pos = find_nearest_valid_position(&mappings, 0, None).unwrap();
488488+ assert_eq!(pos.char_offset(), 2);
489489+ assert_eq!(pos.snapped, Some(SnapDirection::Forward));
490490+ }
491491+492492+ #[test]
493493+ fn test_find_nearest_valid_position_snap_forward_from_invisible() {
494494+ let mappings = make_test_mappings();
495495+496496+ // Position 10 is in invisible range (5..15), forward finds visible (15..20)
497497+ let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Forward)).unwrap();
498498+ assert_eq!(pos.char_offset(), 15);
499499+ assert_eq!(pos.snapped, Some(SnapDirection::Forward));
500500+ }
501501+502502+ #[test]
503503+ fn test_is_valid_cursor_position() {
504504+ let mappings = make_test_mappings();
505505+506506+ // Invisible positions
507507+ assert!(!is_valid_cursor_position(&mappings, 0));
508508+ assert!(!is_valid_cursor_position(&mappings, 1));
509509+ assert!(!is_valid_cursor_position(&mappings, 10));
510510+511511+ // Visible positions
512512+ assert!(is_valid_cursor_position(&mappings, 2));
513513+ assert!(is_valid_cursor_position(&mappings, 3));
514514+ assert!(is_valid_cursor_position(&mappings, 4));
515515+ assert!(is_valid_cursor_position(&mappings, 15));
516516+ assert!(is_valid_cursor_position(&mappings, 17));
517517+ }
518518+519519+ #[test]
520520+ fn test_find_nearest_valid_position_empty() {
521521+ let mappings: Vec<OffsetMapping> = vec![];
522522+ assert!(find_nearest_valid_position(&mappings, 0, None).is_none());
523523+ }
524524+}
+122
crates/weaver-editor-core/src/paragraph.rs
···11+//! Paragraph-level rendering for incremental updates.
22+//!
33+//! Paragraphs are discovered during markdown rendering by tracking
44+//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM.
55+66+use smol_str::{SmolStr, format_smolstr};
77+88+use crate::offset_map::OffsetMapping;
99+use crate::syntax::SyntaxSpanInfo;
1010+use std::collections::hash_map::DefaultHasher;
1111+use std::hash::{Hash, Hasher};
1212+use std::ops::Range;
1313+1414+/// A rendered paragraph with its source range and offset mappings.
1515+#[derive(Debug, Clone, PartialEq)]
1616+pub struct ParagraphRender {
1717+ /// Stable content-based ID for DOM diffing (format: `p-{index}`)
1818+ pub id: SmolStr,
1919+2020+ /// Source byte range in the text buffer
2121+ pub byte_range: Range<usize>,
2222+2323+ /// Source char range in the text buffer
2424+ pub char_range: Range<usize>,
2525+2626+ /// Rendered HTML content (without wrapper div)
2727+ pub html: String,
2828+2929+ /// Offset mappings for this paragraph
3030+ pub offset_map: Vec<OffsetMapping>,
3131+3232+ /// Syntax spans for conditional visibility
3333+ pub syntax_spans: Vec<SyntaxSpanInfo>,
3434+3535+ /// Hash of source text for quick change detection
3636+ pub source_hash: u64,
3737+}
3838+3939+impl ParagraphRender {
4040+ /// Check if this paragraph contains a given byte offset.
4141+ pub fn contains_byte(&self, offset: usize) -> bool {
4242+ self.byte_range.contains(&offset)
4343+ }
4444+4545+ /// Check if this paragraph contains a given char offset.
4646+ pub fn contains_char(&self, offset: usize) -> bool {
4747+ self.char_range.contains(&offset)
4848+ }
4949+5050+ /// Get the length in chars.
5151+ pub fn char_len(&self) -> usize {
5252+ self.char_range.len()
5353+ }
5454+5555+ /// Get the length in bytes.
5656+ pub fn byte_len(&self) -> usize {
5757+ self.byte_range.len()
5858+ }
5959+}
6060+6161+/// Simple hash function for source text comparison.
6262+///
6363+/// Used to quickly detect if paragraph content has changed.
6464+pub fn hash_source(text: &str) -> u64 {
6565+ let mut hasher = DefaultHasher::new();
6666+ text.hash(&mut hasher);
6767+ hasher.finish()
6868+}
6969+7070+/// Generate a paragraph ID from monotonic counter.
7171+///
7272+/// IDs are stable across content changes - only position/cursor determines identity.
7373+pub fn make_paragraph_id(index: usize) -> SmolStr {
7474+ format_smolstr!("p-{}", index)
7575+}
7676+7777+#[cfg(test)]
7878+mod tests {
7979+ use smol_str::ToSmolStr;
8080+8181+ use super::*;
8282+8383+ #[test]
8484+ fn test_hash_source() {
8585+ let h1 = hash_source("hello world");
8686+ let h2 = hash_source("hello world");
8787+ let h3 = hash_source("hello world!");
8888+8989+ assert_eq!(h1, h2);
9090+ assert_ne!(h1, h3);
9191+ }
9292+9393+ #[test]
9494+ fn test_make_paragraph_id() {
9595+ assert_eq!(make_paragraph_id(0), "p-0");
9696+ assert_eq!(make_paragraph_id(42), "p-42");
9797+ }
9898+9999+ #[test]
100100+ fn test_paragraph_contains() {
101101+ let para = ParagraphRender {
102102+ id: "p-0".to_smolstr(),
103103+ byte_range: 10..50,
104104+ char_range: 10..50,
105105+ html: String::new(),
106106+ offset_map: vec![],
107107+ syntax_spans: vec![],
108108+ source_hash: 0,
109109+ };
110110+111111+ assert!(!para.contains_byte(9));
112112+ assert!(para.contains_byte(10));
113113+ assert!(para.contains_byte(25));
114114+ assert!(para.contains_byte(49));
115115+ assert!(!para.contains_byte(50));
116116+117117+ assert!(!para.contains_char(9));
118118+ assert!(para.contains_char(10));
119119+ assert!(para.contains_char(25));
120120+ assert!(!para.contains_char(50));
121121+ }
122122+}
+168
crates/weaver-editor-core/src/syntax.rs
···11+//! Syntax span tracking for conditional visibility.
22+//!
33+//! Tracks markdown syntax characters (like `**`, `#`, `>`) so they can be
44+//! shown/hidden based on cursor position (Obsidian-style editing).
55+66+use std::ops::Range;
77+88+use smol_str::SmolStr;
99+1010+/// Classification of markdown syntax characters.
1111+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1212+pub enum SyntaxType {
1313+ /// Inline formatting: **, *, ~~, `, $, [, ], (, )
1414+ Inline,
1515+ /// Block formatting: #, >, -, *, 1., ```, ---
1616+ Block,
1717+}
1818+1919+/// Information about a syntax span for conditional visibility.
2020+#[derive(Debug, Clone, PartialEq, Eq)]
2121+pub struct SyntaxSpanInfo {
2222+ /// Unique identifier for this syntax span (e.g., "s0", "s1")
2323+ pub syn_id: SmolStr,
2424+ /// Source char range this syntax covers (just this marker)
2525+ pub char_range: Range<usize>,
2626+ /// Whether this is inline or block-level syntax
2727+ pub syntax_type: SyntaxType,
2828+ /// For paired inline syntax (**, *, etc), the full formatted region
2929+ /// from opening marker through content to closing marker.
3030+ /// When cursor is anywhere in this range, the syntax is visible.
3131+ pub formatted_range: Option<Range<usize>>,
3232+}
3333+3434+impl SyntaxSpanInfo {
3535+ /// Adjust all position fields by a character delta.
3636+ ///
3737+ /// This adjusts both `char_range` and `formatted_range` (if present) together,
3838+ /// ensuring they stay in sync. Use this instead of manually adjusting fields
3939+ /// to avoid forgetting one.
4040+ pub fn adjust_positions(&mut self, char_delta: isize) {
4141+ self.char_range.start = (self.char_range.start as isize + char_delta) as usize;
4242+ self.char_range.end = (self.char_range.end as isize + char_delta) as usize;
4343+ if let Some(ref mut fr) = self.formatted_range {
4444+ fr.start = (fr.start as isize + char_delta) as usize;
4545+ fr.end = (fr.end as isize + char_delta) as usize;
4646+ }
4747+ }
4848+4949+ /// Check if cursor is within the visibility range for this syntax.
5050+ ///
5151+ /// Returns true if the cursor should cause this syntax to be visible.
5252+ /// Uses `formatted_range` if present (for paired syntax like **bold**),
5353+ /// otherwise uses `char_range` (for standalone syntax like # heading).
5454+ pub fn cursor_in_range(&self, cursor_pos: usize) -> bool {
5555+ let range = self.formatted_range.as_ref().unwrap_or(&self.char_range);
5656+ cursor_pos >= range.start && cursor_pos <= range.end
5757+ }
5858+}
5959+6060+/// Classify syntax text as inline or block level.
6161+pub fn classify_syntax(text: &str) -> SyntaxType {
6262+ let trimmed = text.trim_start();
6363+6464+ // Check for block-level markers
6565+ if trimmed.starts_with('#')
6666+ || trimmed.starts_with('>')
6767+ || trimmed.starts_with("```")
6868+ || trimmed.starts_with("---")
6969+ || (trimmed.starts_with('-')
7070+ && trimmed
7171+ .chars()
7272+ .nth(1)
7373+ .map(|c| c.is_whitespace())
7474+ .unwrap_or(false))
7575+ || (trimmed.starts_with('*')
7676+ && trimmed
7777+ .chars()
7878+ .nth(1)
7979+ .map(|c| c.is_whitespace())
8080+ .unwrap_or(false))
8181+ || trimmed
8282+ .chars()
8383+ .next()
8484+ .map(|c| c.is_ascii_digit())
8585+ .unwrap_or(false)
8686+ && trimmed.contains('.')
8787+ {
8888+ SyntaxType::Block
8989+ } else {
9090+ SyntaxType::Inline
9191+ }
9292+}
9393+9494+#[cfg(test)]
9595+mod tests {
9696+ use smol_str::ToSmolStr;
9797+9898+ use super::*;
9999+100100+ #[test]
101101+ fn test_classify_block_syntax() {
102102+ assert_eq!(classify_syntax("# "), SyntaxType::Block);
103103+ assert_eq!(classify_syntax("## "), SyntaxType::Block);
104104+ assert_eq!(classify_syntax("> "), SyntaxType::Block);
105105+ assert_eq!(classify_syntax("- "), SyntaxType::Block);
106106+ assert_eq!(classify_syntax("* "), SyntaxType::Block);
107107+ assert_eq!(classify_syntax("1. "), SyntaxType::Block);
108108+ assert_eq!(classify_syntax("```"), SyntaxType::Block);
109109+ assert_eq!(classify_syntax("---"), SyntaxType::Block);
110110+ }
111111+112112+ #[test]
113113+ fn test_classify_inline_syntax() {
114114+ assert_eq!(classify_syntax("**"), SyntaxType::Inline);
115115+ assert_eq!(classify_syntax("*"), SyntaxType::Inline);
116116+ assert_eq!(classify_syntax("`"), SyntaxType::Inline);
117117+ assert_eq!(classify_syntax("~~"), SyntaxType::Inline);
118118+ assert_eq!(classify_syntax("["), SyntaxType::Inline);
119119+ assert_eq!(classify_syntax("]("), SyntaxType::Inline);
120120+ }
121121+122122+ #[test]
123123+ fn test_adjust_positions() {
124124+ let mut span = SyntaxSpanInfo {
125125+ syn_id: "s0".to_smolstr(),
126126+ char_range: 10..15,
127127+ syntax_type: SyntaxType::Inline,
128128+ formatted_range: Some(10..25),
129129+ };
130130+131131+ span.adjust_positions(5);
132132+ assert_eq!(span.char_range, 15..20);
133133+ assert_eq!(span.formatted_range, Some(15..30));
134134+135135+ span.adjust_positions(-3);
136136+ assert_eq!(span.char_range, 12..17);
137137+ assert_eq!(span.formatted_range, Some(12..27));
138138+ }
139139+140140+ #[test]
141141+ fn test_cursor_in_range() {
142142+ // Paired syntax with formatted_range
143143+ let span = SyntaxSpanInfo {
144144+ syn_id: "s0".to_smolstr(),
145145+ char_range: 0..2, // **
146146+ syntax_type: SyntaxType::Inline,
147147+ formatted_range: Some(0..10), // **content**
148148+ };
149149+150150+ assert!(span.cursor_in_range(0));
151151+ assert!(span.cursor_in_range(5));
152152+ assert!(span.cursor_in_range(10));
153153+ assert!(!span.cursor_in_range(11));
154154+155155+ // Unpaired syntax without formatted_range
156156+ let span = SyntaxSpanInfo {
157157+ syn_id: "s1".to_smolstr(),
158158+ char_range: 0..2, // ##
159159+ syntax_type: SyntaxType::Block,
160160+ formatted_range: None,
161161+ };
162162+163163+ assert!(span.cursor_in_range(0));
164164+ assert!(span.cursor_in_range(1));
165165+ assert!(span.cursor_in_range(2));
166166+ assert!(!span.cursor_in_range(3));
167167+ }
168168+}
+203
crates/weaver-editor-core/src/text.rs
···11+//! Text buffer abstraction for editor storage.
22+//!
33+//! The `TextBuffer` trait provides a common interface for text storage,
44+//! allowing the editor to work with different backends (ropey for local,
55+//! Loro for CRDT collaboration).
66+77+use smol_str::{SmolStr, ToSmolStr};
88+use std::ops::Range;
99+1010+/// A text buffer that supports efficient editing and offset conversion.
1111+///
1212+/// All offsets are in Unicode scalar values (chars), not bytes or UTF-16.
1313+pub trait TextBuffer: Default + Clone {
1414+ /// Total length in bytes (UTF-8).
1515+ fn len_bytes(&self) -> usize;
1616+1717+ /// Total length in chars (Unicode scalar values).
1818+ fn len_chars(&self) -> usize;
1919+2020+ /// Check if empty.
2121+ fn is_empty(&self) -> bool {
2222+ self.len_chars() == 0
2323+ }
2424+2525+ /// Insert text at char offset.
2626+ fn insert(&mut self, char_offset: usize, text: &str);
2727+2828+ /// Delete char range.
2929+ fn delete(&mut self, char_range: Range<usize>);
3030+3131+ /// Replace char range with text.
3232+ fn replace(&mut self, char_range: Range<usize>, text: &str) {
3333+ self.delete(char_range.clone());
3434+ self.insert(char_range.start, text);
3535+ }
3636+3737+ /// Get a slice as SmolStr. Returns None if range is invalid.
3838+ ///
3939+ /// SmolStr is used for efficiency: strings ≤23 bytes are stored inline
4040+ /// (no heap allocation), longer strings are Arc'd (cheap to clone).
4141+ fn slice(&self, char_range: Range<usize>) -> Option<SmolStr>;
4242+4343+ /// Get character at offset. Returns None if out of bounds.
4444+ fn char_at(&self, char_offset: usize) -> Option<char>;
4545+4646+ /// Convert entire buffer to String.
4747+ fn to_string(&self) -> String;
4848+4949+ /// Convert char offset to byte offset.
5050+ fn char_to_byte(&self, char_offset: usize) -> usize;
5151+5252+ /// Convert byte offset to char offset.
5353+ fn byte_to_char(&self, byte_offset: usize) -> usize;
5454+}
5555+5656+/// Ropey-backed text buffer for local editing.
5757+///
5858+/// Provides O(log n) editing operations and offset conversions.
5959+#[derive(Clone, Default)]
6060+pub struct EditorRope {
6161+ rope: ropey::Rope,
6262+}
6363+6464+impl EditorRope {
6565+ /// Create a new empty rope.
6666+ pub fn new() -> Self {
6767+ Self::default()
6868+ }
6969+7070+ /// Create from string.
7171+ pub fn from_str(s: &str) -> Self {
7272+ Self {
7373+ rope: ropey::Rope::from_str(s),
7474+ }
7575+ }
7676+7777+ /// Get a reference to the underlying rope (for advanced operations).
7878+ pub fn rope(&self) -> &ropey::Rope {
7979+ &self.rope
8080+ }
8181+8282+ /// Get a rope slice for zero-copy iteration over chunks.
8383+ ///
8484+ /// Use this when you need to iterate over the text without allocating,
8585+ /// e.g., for hashing or character-by-character processing.
8686+ pub fn rope_slice(&self, char_range: Range<usize>) -> Option<ropey::RopeSlice<'_>> {
8787+ if char_range.end > self.rope.len_chars() {
8888+ return None;
8989+ }
9090+ Some(self.rope.slice(char_range))
9191+ }
9292+}
9393+9494+impl TextBuffer for EditorRope {
9595+ fn len_bytes(&self) -> usize {
9696+ self.rope.len_bytes()
9797+ }
9898+9999+ fn len_chars(&self) -> usize {
100100+ self.rope.len_chars()
101101+ }
102102+103103+ fn insert(&mut self, char_offset: usize, text: &str) {
104104+ self.rope.insert(char_offset, text);
105105+ }
106106+107107+ fn delete(&mut self, char_range: Range<usize>) {
108108+ self.rope.remove(char_range);
109109+ }
110110+111111+ fn slice(&self, char_range: Range<usize>) -> Option<SmolStr> {
112112+ if char_range.end > self.len_chars() {
113113+ return None;
114114+ }
115115+ Some(self.rope.slice(char_range).to_smolstr())
116116+ }
117117+118118+ fn char_at(&self, char_offset: usize) -> Option<char> {
119119+ if char_offset >= self.len_chars() {
120120+ return None;
121121+ }
122122+ Some(self.rope.char(char_offset))
123123+ }
124124+125125+ fn to_string(&self) -> String {
126126+ self.rope.to_string()
127127+ }
128128+129129+ fn char_to_byte(&self, char_offset: usize) -> usize {
130130+ self.rope.char_to_byte(char_offset)
131131+ }
132132+133133+ fn byte_to_char(&self, byte_offset: usize) -> usize {
134134+ self.rope.byte_to_char(byte_offset)
135135+ }
136136+}
137137+138138+impl From<&str> for EditorRope {
139139+ fn from(s: &str) -> Self {
140140+ Self::from_str(s)
141141+ }
142142+}
143143+144144+impl From<String> for EditorRope {
145145+ fn from(s: String) -> Self {
146146+ Self::from_str(&s)
147147+ }
148148+}
149149+150150+#[cfg(test)]
151151+mod tests {
152152+ use super::*;
153153+154154+ #[test]
155155+ fn test_basic_operations() {
156156+ let mut rope = EditorRope::from_str("hello world");
157157+ assert_eq!(rope.len_chars(), 11);
158158+ assert_eq!(rope.to_string(), "hello world");
159159+160160+ rope.insert(5, " beautiful");
161161+ assert_eq!(rope.to_string(), "hello beautiful world");
162162+163163+ // " beautiful" is 10 chars at positions 5..15
164164+ rope.delete(5..15);
165165+ assert_eq!(rope.to_string(), "hello world");
166166+ }
167167+168168+ #[test]
169169+ fn test_char_at() {
170170+ let rope = EditorRope::from_str("hello");
171171+ assert_eq!(rope.char_at(0), Some('h'));
172172+ assert_eq!(rope.char_at(4), Some('o'));
173173+ assert_eq!(rope.char_at(5), None);
174174+ }
175175+176176+ #[test]
177177+ fn test_slice() {
178178+ let rope = EditorRope::from_str("hello world");
179179+ assert_eq!(rope.slice(0..5).as_deref(), Some("hello"));
180180+ assert_eq!(rope.slice(6..11).as_deref(), Some("world"));
181181+ assert_eq!(rope.slice(0..100), None);
182182+ }
183183+184184+ #[test]
185185+ fn test_offset_conversion() {
186186+ // "hello 🌍" - emoji is 4 bytes, 1 char
187187+ let rope = EditorRope::from_str("hello 🌍");
188188+ assert_eq!(rope.len_chars(), 7); // h e l l o 🌍
189189+ assert_eq!(rope.len_bytes(), 10); // 6 + 4
190190+191191+ assert_eq!(rope.char_to_byte(6), 6); // before emoji
192192+ assert_eq!(rope.char_to_byte(7), 10); // after emoji
193193+ assert_eq!(rope.byte_to_char(6), 6);
194194+ assert_eq!(rope.byte_to_char(10), 7);
195195+ }
196196+197197+ #[test]
198198+ fn test_replace() {
199199+ let mut rope = EditorRope::from_str("hello world");
200200+ rope.replace(6..11, "rust");
201201+ assert_eq!(rope.to_string(), "hello rust");
202202+ }
203203+}
+319
crates/weaver-editor-core/src/types.rs
···11+//! Core editor types: cursor, selection, composition, and edit tracking.
22+//!
33+//! These types are framework-agnostic and can be used with any text buffer implementation.
44+55+use std::ops::Range;
66+use web_time::Instant;
77+88+/// Cursor state including position and affinity.
99+#[derive(Clone, Debug, Copy, PartialEq, Eq)]
1010+pub struct CursorState {
1111+ /// Character offset in text (NOT byte offset!)
1212+ pub offset: usize,
1313+1414+ /// Prefer left/right when at boundary (for vertical cursor movement)
1515+ pub affinity: Affinity,
1616+}
1717+1818+impl Default for CursorState {
1919+ fn default() -> Self {
2020+ Self {
2121+ offset: 0,
2222+ affinity: Affinity::Before,
2323+ }
2424+ }
2525+}
2626+2727+impl CursorState {
2828+ /// Create a new cursor at the given offset.
2929+ pub fn new(offset: usize) -> Self {
3030+ Self {
3131+ offset,
3232+ affinity: Affinity::Before,
3333+ }
3434+ }
3535+3636+ /// Create a cursor with specific affinity.
3737+ pub fn with_affinity(offset: usize, affinity: Affinity) -> Self {
3838+ Self { offset, affinity }
3939+ }
4040+}
4141+4242+/// Cursor affinity for vertical movement.
4343+///
4444+/// When navigating vertically, the cursor needs to know which side of a line
4545+/// break it prefers. `Before` means stick to the end of the previous line,
4646+/// `After` means stick to the start of the next line.
4747+#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)]
4848+pub enum Affinity {
4949+ #[default]
5050+ Before,
5151+ After,
5252+}
5353+5454+/// Text selection with anchor and head positions.
5555+///
5656+/// The anchor is where the selection started, the head is where the cursor is now.
5757+/// They may be in any order - use `start()` and `end()` for ordered bounds.
5858+#[derive(Clone, Debug, Copy, PartialEq, Eq)]
5959+pub struct Selection {
6060+ /// Where selection started
6161+ pub anchor: usize,
6262+ /// Where cursor is now
6363+ pub head: usize,
6464+}
6565+6666+impl Selection {
6767+ /// Create a new selection.
6868+ pub fn new(anchor: usize, head: usize) -> Self {
6969+ Self { anchor, head }
7070+ }
7171+7272+ /// Create a collapsed selection (cursor position).
7373+ pub fn collapsed(offset: usize) -> Self {
7474+ Self {
7575+ anchor: offset,
7676+ head: offset,
7777+ }
7878+ }
7979+8080+ /// Get the start (lower bound) of the selection.
8181+ pub fn start(&self) -> usize {
8282+ self.anchor.min(self.head)
8383+ }
8484+8585+ /// Get the end (upper bound) of the selection.
8686+ pub fn end(&self) -> usize {
8787+ self.anchor.max(self.head)
8888+ }
8989+9090+ /// Check if the selection is collapsed (empty, cursor only).
9191+ pub fn is_collapsed(&self) -> bool {
9292+ self.anchor == self.head
9393+ }
9494+9595+ /// Check if an offset is within the selection.
9696+ pub fn contains(&self, offset: usize) -> bool {
9797+ offset >= self.start() && offset < self.end()
9898+ }
9999+100100+ /// Get the selection length.
101101+ pub fn len(&self) -> usize {
102102+ self.end() - self.start()
103103+ }
104104+105105+ /// Check if empty (same as is_collapsed).
106106+ pub fn is_empty(&self) -> bool {
107107+ self.is_collapsed()
108108+ }
109109+110110+ /// Convert to a Range<usize> (ordered).
111111+ pub fn to_range(&self) -> Range<usize> {
112112+ self.start()..self.end()
113113+ }
114114+115115+ /// Check if the selection is backwards (head before anchor).
116116+ pub fn is_backwards(&self) -> bool {
117117+ self.head < self.anchor
118118+ }
119119+}
120120+121121+/// IME composition state (for international text input).
122122+///
123123+/// During IME composition, the user is building up a string of characters
124124+/// that hasn't been committed yet. This tracks where that composition
125125+/// started and what text is currently being composed.
126126+#[derive(Clone, Debug, PartialEq, Eq)]
127127+pub struct CompositionState {
128128+ /// Character offset where composition started
129129+ pub start_offset: usize,
130130+ /// Current composition text (uncommitted)
131131+ pub text: String,
132132+}
133133+134134+impl CompositionState {
135135+ /// Create a new composition state.
136136+ pub fn new(start_offset: usize, text: String) -> Self {
137137+ Self { start_offset, text }
138138+ }
139139+140140+ /// Get the end offset of the composition.
141141+ pub fn end_offset(&self) -> usize {
142142+ self.start_offset + self.text.chars().count()
143143+ }
144144+145145+ /// Check if an offset is within the composition.
146146+ pub fn contains(&self, offset: usize) -> bool {
147147+ offset >= self.start_offset && offset < self.end_offset()
148148+ }
149149+}
150150+151151+/// Information about the most recent edit, used for incremental rendering optimization.
152152+///
153153+/// This tracks enough information to determine which paragraphs need re-rendering
154154+/// after an edit, enabling efficient incremental updates instead of full re-renders.
155155+#[derive(Clone, Debug)]
156156+pub struct EditInfo {
157157+ /// Character offset where the edit occurred
158158+ pub edit_char_pos: usize,
159159+ /// Number of characters inserted
160160+ pub inserted_len: usize,
161161+ /// Number of characters deleted
162162+ pub deleted_len: usize,
163163+ /// Whether the edit contains a newline (boundary-affecting)
164164+ pub contains_newline: bool,
165165+ /// Whether the edit is in the block-syntax zone of a line (first ~6 chars).
166166+ /// Edits here could affect block-level syntax like headings, lists, code fences.
167167+ pub in_block_syntax_zone: bool,
168168+ /// Document length (in chars) after this edit was applied.
169169+ /// Used to detect stale edit info - if current doc length doesn't match,
170170+ /// the edit info is from a previous render cycle and shouldn't be used.
171171+ pub doc_len_after: usize,
172172+ /// When this edit occurred. Used for idle detection in collaborative sync.
173173+ pub timestamp: Instant,
174174+}
175175+176176+impl PartialEq for EditInfo {
177177+ fn eq(&self, other: &Self) -> bool {
178178+ // Compare all fields except timestamp (not meaningful for equality)
179179+ self.edit_char_pos == other.edit_char_pos
180180+ && self.inserted_len == other.inserted_len
181181+ && self.deleted_len == other.deleted_len
182182+ && self.contains_newline == other.contains_newline
183183+ && self.in_block_syntax_zone == other.in_block_syntax_zone
184184+ && self.doc_len_after == other.doc_len_after
185185+ }
186186+}
187187+188188+impl EditInfo {
189189+ /// Check if this edit info is stale (doc has changed since this edit).
190190+ pub fn is_stale(&self, current_doc_len: usize) -> bool {
191191+ self.doc_len_after != current_doc_len
192192+ }
193193+194194+ /// Check if this edit might affect paragraph boundaries.
195195+ pub fn affects_boundaries(&self) -> bool {
196196+ self.contains_newline || self.in_block_syntax_zone
197197+ }
198198+199199+ /// Get the range that was affected by this edit.
200200+ ///
201201+ /// For insertions: the range of inserted text.
202202+ /// For deletions: an empty range at the deletion point.
203203+ /// For replacements: the range of inserted text.
204204+ pub fn affected_range(&self) -> Range<usize> {
205205+ self.edit_char_pos..self.edit_char_pos + self.inserted_len
206206+ }
207207+}
208208+209209+/// Max distance from line start where block syntax can appear.
210210+/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5)
211211+pub const BLOCK_SYNTAX_ZONE: usize = 6;
212212+213213+#[cfg(test)]
214214+mod tests {
215215+ use super::*;
216216+217217+ #[test]
218218+ fn test_selection_bounds() {
219219+ // Forward selection
220220+ let sel = Selection::new(5, 10);
221221+ assert_eq!(sel.start(), 5);
222222+ assert_eq!(sel.end(), 10);
223223+ assert!(!sel.is_backwards());
224224+225225+ // Backward selection
226226+ let sel = Selection::new(10, 5);
227227+ assert_eq!(sel.start(), 5);
228228+ assert_eq!(sel.end(), 10);
229229+ assert!(sel.is_backwards());
230230+ }
231231+232232+ #[test]
233233+ fn test_selection_collapsed() {
234234+ let sel = Selection::collapsed(7);
235235+ assert!(sel.is_collapsed());
236236+ assert!(sel.is_empty());
237237+ assert_eq!(sel.len(), 0);
238238+ assert_eq!(sel.start(), 7);
239239+ assert_eq!(sel.end(), 7);
240240+ }
241241+242242+ #[test]
243243+ fn test_selection_contains() {
244244+ let sel = Selection::new(5, 10);
245245+ assert!(!sel.contains(4));
246246+ assert!(sel.contains(5));
247247+ assert!(sel.contains(7));
248248+ assert!(sel.contains(9));
249249+ assert!(!sel.contains(10)); // end is exclusive
250250+ }
251251+252252+ #[test]
253253+ fn test_selection_to_range() {
254254+ let sel = Selection::new(10, 5);
255255+ assert_eq!(sel.to_range(), 5..10);
256256+ }
257257+258258+ #[test]
259259+ fn test_composition_contains() {
260260+ let comp = CompositionState::new(10, "你好".to_string());
261261+ assert_eq!(comp.end_offset(), 12); // 2 chars
262262+ assert!(!comp.contains(9));
263263+ assert!(comp.contains(10));
264264+ assert!(comp.contains(11));
265265+ assert!(!comp.contains(12)); // end is exclusive
266266+ }
267267+268268+ #[test]
269269+ fn test_edit_info_stale() {
270270+ let edit = EditInfo {
271271+ edit_char_pos: 5,
272272+ inserted_len: 3,
273273+ deleted_len: 0,
274274+ contains_newline: false,
275275+ in_block_syntax_zone: false,
276276+ doc_len_after: 100,
277277+ timestamp: Instant::now(),
278278+ };
279279+280280+ assert!(!edit.is_stale(100));
281281+ assert!(edit.is_stale(101));
282282+ }
283283+284284+ #[test]
285285+ fn test_edit_info_affects_boundaries() {
286286+ let edit = EditInfo {
287287+ edit_char_pos: 0,
288288+ inserted_len: 1,
289289+ deleted_len: 0,
290290+ contains_newline: false,
291291+ in_block_syntax_zone: true,
292292+ doc_len_after: 100,
293293+ timestamp: Instant::now(),
294294+ };
295295+ assert!(edit.affects_boundaries());
296296+297297+ let edit = EditInfo {
298298+ edit_char_pos: 50,
299299+ inserted_len: 1,
300300+ deleted_len: 0,
301301+ contains_newline: true,
302302+ in_block_syntax_zone: false,
303303+ doc_len_after: 100,
304304+ timestamp: Instant::now(),
305305+ };
306306+ assert!(edit.affects_boundaries());
307307+308308+ let edit = EditInfo {
309309+ edit_char_pos: 50,
310310+ inserted_len: 1,
311311+ deleted_len: 0,
312312+ contains_newline: false,
313313+ in_block_syntax_zone: false,
314314+ doc_len_after: 100,
315315+ timestamp: Instant::now(),
316316+ };
317317+ assert!(!edit.affects_boundaries());
318318+ }
319319+}
+396
crates/weaver-editor-core/src/visibility.rs
···11+//! Conditional syntax visibility based on cursor position.
22+//!
33+//! Implements Obsidian-style formatting character visibility: syntax markers
44+//! are hidden when cursor is not near them, revealed when cursor approaches.
55+66+use smol_str::SmolStr;
77+88+use crate::paragraph::ParagraphRender;
99+use crate::syntax::{SyntaxSpanInfo, SyntaxType};
1010+use crate::types::Selection;
1111+use std::collections::HashSet;
1212+use std::ops::Range;
1313+1414+/// Determines which syntax spans should be visible based on cursor/selection.
1515+#[derive(Debug, Clone, Default)]
1616+pub struct VisibilityState {
1717+ /// Set of syn_ids that should be visible
1818+ pub visible_span_ids: HashSet<SmolStr>,
1919+}
2020+2121+impl VisibilityState {
2222+ /// Calculate visibility based on cursor position and selection.
2323+ pub fn calculate(
2424+ cursor_offset: usize,
2525+ selection: Option<&Selection>,
2626+ syntax_spans: &[SyntaxSpanInfo],
2727+ paragraphs: &[ParagraphRender],
2828+ ) -> Self {
2929+ let mut visible = HashSet::new();
3030+3131+ for span in syntax_spans {
3232+ // Find the paragraph containing this span for boundary clamping
3333+ let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
3434+3535+ let should_show = match span.syntax_type {
3636+ SyntaxType::Inline => {
3737+ // Show if cursor within formatted span content OR adjacent to markers
3838+ // "Adjacent" means within 1 char of the syntax boundaries,
3939+ // clamped to paragraph bounds (paragraphs are split by newlines,
4040+ // so clamping to para bounds prevents cross-line extension)
4141+ let extended_start =
4242+ safe_extend_left(span.char_range.start, 1, para_bounds.as_ref());
4343+ let extended_end =
4444+ safe_extend_right(span.char_range.end, 1, para_bounds.as_ref());
4545+ let extended_range = extended_start..extended_end;
4646+4747+ // Also show if cursor is anywhere in the formatted_range
4848+ // (the region between paired opening/closing markers)
4949+ // Extend by 1 char on BOTH sides for symmetric "approaching" behavior,
5050+ // clamped to paragraph bounds.
5151+ let in_formatted_region = span
5252+ .formatted_range
5353+ .as_ref()
5454+ .map(|r| {
5555+ let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref());
5656+ let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref());
5757+ cursor_offset >= ext_start && cursor_offset <= ext_end
5858+ })
5959+ .unwrap_or(false);
6060+6161+ let in_extended = extended_range.contains(&cursor_offset);
6262+ in_extended
6363+ || in_formatted_region
6464+ || selection_overlaps(selection, &span.char_range)
6565+ || span
6666+ .formatted_range
6767+ .as_ref()
6868+ .map(|r| selection_overlaps(selection, r))
6969+ .unwrap_or(false)
7070+ }
7171+ SyntaxType::Block => {
7272+ // Show if cursor anywhere in same paragraph (with slop for edge cases)
7373+ // The slop handles typing at the end of a heading like "# |"
7474+ let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs);
7575+ let in_paragraph = para_bounds
7676+ .as_ref()
7777+ .map(|p| {
7878+ // Extend paragraph bounds by 1 char on each side for slop
7979+ let ext_start = p.start.saturating_sub(1);
8080+ let ext_end = p.end.saturating_add(1);
8181+ cursor_offset >= ext_start && cursor_offset <= ext_end
8282+ })
8383+ .unwrap_or(false);
8484+8585+ in_paragraph || selection_overlaps(selection, &span.char_range)
8686+ }
8787+ };
8888+8989+ if should_show {
9090+ visible.insert(span.syn_id.clone());
9191+ }
9292+ }
9393+9494+ tracing::debug!(
9595+ target: "weaver::visibility",
9696+ cursor_offset,
9797+ total_spans = syntax_spans.len(),
9898+ visible_count = visible.len(),
9999+ "calculated visibility"
100100+ );
101101+102102+ Self {
103103+ visible_span_ids: visible,
104104+ }
105105+ }
106106+107107+ /// Check if a specific span should be visible.
108108+ pub fn is_visible(&self, syn_id: &str) -> bool {
109109+ self.visible_span_ids.contains(syn_id)
110110+ }
111111+112112+ /// Get the number of visible spans.
113113+ pub fn visible_count(&self) -> usize {
114114+ self.visible_span_ids.len()
115115+ }
116116+117117+ /// Check if any spans are visible.
118118+ pub fn has_visible(&self) -> bool {
119119+ !self.visible_span_ids.is_empty()
120120+ }
121121+}
122122+123123+/// Check if selection overlaps with a char range.
124124+fn selection_overlaps(selection: Option<&Selection>, range: &Range<usize>) -> bool {
125125+ let Some(sel) = selection else {
126126+ return false;
127127+ };
128128+129129+ let sel_start = sel.start();
130130+ let sel_end = sel.end();
131131+132132+ // Check if ranges overlap
133133+ sel_start < range.end && sel_end > range.start
134134+}
135135+136136+/// Find the paragraph bounds containing a syntax span.
137137+fn find_paragraph_bounds(
138138+ syntax_range: &Range<usize>,
139139+ paragraphs: &[ParagraphRender],
140140+) -> Option<Range<usize>> {
141141+ for para in paragraphs {
142142+ // Skip gap paragraphs
143143+ if para.syntax_spans.is_empty() && !para.char_range.is_empty() {
144144+ continue;
145145+ }
146146+147147+ if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end {
148148+ return Some(para.char_range.clone());
149149+ }
150150+ }
151151+ None
152152+}
153153+154154+/// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds.
155155+///
156156+/// Paragraphs are already split by newlines, so clamping to paragraph bounds
157157+/// naturally prevents extending across line boundaries.
158158+fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
159159+ let min_pos = para_bounds.map(|p| p.start).unwrap_or(0);
160160+ pos.saturating_sub(amount).max(min_pos)
161161+}
162162+163163+/// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds.
164164+///
165165+/// Paragraphs are already split by newlines, so clamping to paragraph bounds
166166+/// naturally prevents extending across line boundaries.
167167+fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range<usize>>) -> usize {
168168+ let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX);
169169+ pos.saturating_add(amount).min(max_pos)
170170+}
171171+172172+#[cfg(test)]
173173+mod tests {
174174+ use smol_str::{ToSmolStr, format_smolstr};
175175+176176+ use super::*;
177177+178178+ fn make_span(
179179+ syn_id: &str,
180180+ start: usize,
181181+ end: usize,
182182+ syntax_type: SyntaxType,
183183+ ) -> SyntaxSpanInfo {
184184+ SyntaxSpanInfo {
185185+ syn_id: syn_id.to_smolstr(),
186186+ char_range: start..end,
187187+ syntax_type,
188188+ formatted_range: None,
189189+ }
190190+ }
191191+192192+ fn make_span_with_range(
193193+ syn_id: &str,
194194+ start: usize,
195195+ end: usize,
196196+ syntax_type: SyntaxType,
197197+ formatted_range: Range<usize>,
198198+ ) -> SyntaxSpanInfo {
199199+ SyntaxSpanInfo {
200200+ syn_id: syn_id.to_smolstr(),
201201+ char_range: start..end,
202202+ syntax_type,
203203+ formatted_range: Some(formatted_range),
204204+ }
205205+ }
206206+207207+ fn make_para(start: usize, end: usize, syntax_spans: Vec<SyntaxSpanInfo>) -> ParagraphRender {
208208+ ParagraphRender {
209209+ id: format_smolstr!("test-{}-{}", start, end),
210210+ byte_range: start..end,
211211+ char_range: start..end,
212212+ html: String::new(),
213213+ offset_map: vec![],
214214+ syntax_spans,
215215+ source_hash: 0,
216216+ }
217217+ }
218218+219219+ #[test]
220220+ fn test_inline_visibility_cursor_inside() {
221221+ // **bold** at chars 0-2 (opening **) and 6-8 (closing **)
222222+ // Text positions: 0-1 = **, 2-5 = bold, 6-7 = **
223223+ // formatted_range is 0..8 (the whole **bold** region)
224224+ let spans = vec![
225225+ make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
226226+ make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
227227+ ];
228228+ let paras = vec![make_para(0, 8, spans.clone())];
229229+230230+ // Cursor at position 4 (middle of "bold", inside formatted region)
231231+ let vis = VisibilityState::calculate(4, None, &spans, ¶s);
232232+ assert!(
233233+ vis.is_visible("s0"),
234234+ "opening ** should be visible when cursor inside formatted region"
235235+ );
236236+ assert!(
237237+ vis.is_visible("s1"),
238238+ "closing ** should be visible when cursor inside formatted region"
239239+ );
240240+241241+ // Cursor at position 2 (adjacent to opening **, start of "bold")
242242+ let vis = VisibilityState::calculate(2, None, &spans, ¶s);
243243+ assert!(
244244+ vis.is_visible("s0"),
245245+ "opening ** should be visible when cursor adjacent at start of bold"
246246+ );
247247+248248+ // Cursor at position 5 (adjacent to closing **, end of "bold")
249249+ let vis = VisibilityState::calculate(5, None, &spans, ¶s);
250250+ assert!(
251251+ vis.is_visible("s1"),
252252+ "closing ** should be visible when cursor adjacent at end of bold"
253253+ );
254254+ }
255255+256256+ #[test]
257257+ fn test_inline_visibility_without_formatted_range() {
258258+ // Test without formatted_range - just adjacency-based visibility
259259+ let spans = vec![
260260+ make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range)
261261+ make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range)
262262+ ];
263263+ let paras = vec![make_para(0, 8, spans.clone())];
264264+265265+ // Cursor at position 4 (middle of "bold", not adjacent to either marker)
266266+ let vis = VisibilityState::calculate(4, None, &spans, ¶s);
267267+ assert!(
268268+ !vis.is_visible("s0"),
269269+ "opening ** should be hidden when no formatted_range and cursor not adjacent"
270270+ );
271271+ assert!(
272272+ !vis.is_visible("s1"),
273273+ "closing ** should be hidden when no formatted_range and cursor not adjacent"
274274+ );
275275+ }
276276+277277+ #[test]
278278+ fn test_inline_visibility_cursor_adjacent() {
279279+ // "test **bold** after"
280280+ // 5 7
281281+ let spans = vec![
282282+ make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6
283283+ ];
284284+ let paras = vec![make_para(0, 19, spans.clone())];
285285+286286+ // Cursor at position 4 (one before ** which starts at 5)
287287+ let vis = VisibilityState::calculate(4, None, &spans, ¶s);
288288+ assert!(
289289+ vis.is_visible("s0"),
290290+ "** should be visible when cursor adjacent"
291291+ );
292292+293293+ // Cursor at position 7 (one after ** which ends at 6, since range is exclusive)
294294+ let vis = VisibilityState::calculate(7, None, &spans, ¶s);
295295+ assert!(
296296+ vis.is_visible("s0"),
297297+ "** should be visible when cursor adjacent after span"
298298+ );
299299+ }
300300+301301+ #[test]
302302+ fn test_inline_visibility_cursor_far() {
303303+ let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)];
304304+ let paras = vec![make_para(0, 33, spans.clone())];
305305+306306+ // Cursor at position 0 (far from **)
307307+ let vis = VisibilityState::calculate(0, None, &spans, ¶s);
308308+ assert!(
309309+ !vis.is_visible("s0"),
310310+ "** should be hidden when cursor far away"
311311+ );
312312+ }
313313+314314+ #[test]
315315+ fn test_block_visibility_same_paragraph() {
316316+ // # at start of heading
317317+ let spans = vec![
318318+ make_span("s0", 0, 2, SyntaxType::Block), // "# "
319319+ ];
320320+ let paras = vec![
321321+ make_para(0, 10, spans.clone()), // heading paragraph
322322+ make_para(12, 30, vec![]), // next paragraph
323323+ ];
324324+325325+ // Cursor at position 5 (inside heading)
326326+ let vis = VisibilityState::calculate(5, None, &spans, ¶s);
327327+ assert!(
328328+ vis.is_visible("s0"),
329329+ "# should be visible when cursor in same paragraph"
330330+ );
331331+ }
332332+333333+ #[test]
334334+ fn test_block_visibility_different_paragraph() {
335335+ let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)];
336336+ let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])];
337337+338338+ // Cursor at position 20 (in second paragraph)
339339+ let vis = VisibilityState::calculate(20, None, &spans, ¶s);
340340+ assert!(
341341+ !vis.is_visible("s0"),
342342+ "# should be hidden when cursor in different paragraph"
343343+ );
344344+ }
345345+346346+ #[test]
347347+ fn test_selection_reveals_syntax() {
348348+ let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)];
349349+ let paras = vec![make_para(0, 24, spans.clone())];
350350+351351+ // Selection overlaps the syntax span
352352+ let selection = Selection::new(3, 10);
353353+ let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s);
354354+ assert!(
355355+ vis.is_visible("s0"),
356356+ "** should be visible when selection overlaps"
357357+ );
358358+ }
359359+360360+ #[test]
361361+ fn test_paragraph_boundary_blocks_extension() {
362362+ // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1,
363363+ // even if cursor is only 1 char after the paragraph boundary
364364+ // (paragraph bounds clamp the extension)
365365+ let spans = vec![
366366+ make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening **
367367+ make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing **
368368+ ];
369369+ let paras = vec![
370370+ make_para(0, 8, spans.clone()), // "**bold**"
371371+ make_para(9, 13, vec![]), // "text" (after newline)
372372+ ];
373373+374374+ // Cursor at position 9 (start of second paragraph)
375375+ // Should NOT reveal the closing ** because para bounds clamp extension
376376+ let vis = VisibilityState::calculate(9, None, &spans, ¶s);
377377+ assert!(
378378+ !vis.is_visible("s1"),
379379+ "closing ** should NOT be visible when cursor is in next paragraph"
380380+ );
381381+ }
382382+383383+ #[test]
384384+ fn test_extension_clamps_to_paragraph() {
385385+ // Syntax at very start of paragraph - extension left should stop at para start
386386+ let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)];
387387+ let paras = vec![make_para(0, 8, spans.clone())];
388388+389389+ // Cursor at position 0 - should still see the opening **
390390+ let vis = VisibilityState::calculate(0, None, &spans, ¶s);
391391+ assert!(
392392+ vis.is_visible("s0"),
393393+ "** at start should be visible when cursor at position 0"
394394+ );
395395+ }
396396+}