diff --git a/Cargo.lock b/Cargo.lock index 6926ac3..26b7698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9083,6 +9083,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + [[package]] name = "rouille" version = "3.6.2" @@ -10198,6 +10208,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "strict-num" version = "0.1.1" @@ -12105,6 +12121,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "weaver-editor-core" +version = "0.1.0" +dependencies = [ + "ropey", + "smol_str", + "thiserror 2.0.17", + "tracing", + "web-time", +] + [[package]] name = "weaver-index" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2e31f2d..6133843 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,18 +80,18 @@ strip = "symbols" [profile.wasm-dev] inherits = "dev" debug = true -lto = true +#lto = true [profile.server-dev] inherits = "dev" debug = true -lto = true +#lto = true [profile.server-release] inherits = "release" opt-level = 2 -lto = true +#lto = true [profile.android-dev] inherits = "dev" diff --git a/crates/weaver-editor-core/Cargo.toml b/crates/weaver-editor-core/Cargo.toml new file mode 100644 index 0000000..06d6b18 --- /dev/null +++ b/crates/weaver-editor-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "weaver-editor-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ropey = "1.6" +smol_str = "0.3" +thiserror = { workspace = true } +tracing = { workspace = true } +web-time = "1.1" + +[dev-dependencies] diff --git a/crates/weaver-editor-core/src/lib.rs b/crates/weaver-editor-core/src/lib.rs new file mode 100644 index 0000000..5def3d9 --- /dev/null +++ b/crates/weaver-editor-core/src/lib.rs @@ -0,0 +1,27 @@ +//! weaver-editor-core: Pure Rust editor logic without framework dependencies. +//! +//! This crate provides: +//! - `TextBuffer` trait for text storage abstraction +//! - `EditorRope` - ropey-backed implementation +//! - `EditorDocument` - generic document with undo support +//! - Rendering, actions, formatting - all generic over TextBuffer + +pub mod offset_map; +pub mod paragraph; +pub mod syntax; +pub mod text; +pub mod types; +pub mod visibility; + +pub use offset_map::{ + OffsetMapping, RenderResult, SnapDirection, SnappedPosition, find_mapping_for_byte, + find_mapping_for_char, find_nearest_valid_position, is_valid_cursor_position, +}; +pub use paragraph::{ParagraphRender, hash_source, make_paragraph_id}; +pub use smol_str::SmolStr; +pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; +pub use text::{EditorRope, TextBuffer}; +pub use types::{ + Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE, +}; +pub use visibility::VisibilityState; diff --git a/crates/weaver-editor-core/src/offset_map.rs b/crates/weaver-editor-core/src/offset_map.rs new file mode 100644 index 0000000..cce517a --- /dev/null +++ b/crates/weaver-editor-core/src/offset_map.rs @@ -0,0 +1,524 @@ +//! Offset mapping between source text and rendered DOM. +//! +//! When rendering markdown to HTML, some characters disappear (table pipes) +//! and content gets split across nodes (syntax highlighting). Offset maps +//! track how source byte positions map to DOM node positions. + +use std::ops::Range; + +/// Result of rendering markdown with offset tracking. +#[derive(Debug, Clone, PartialEq)] +pub struct RenderResult { + /// Rendered HTML string + pub html: String, + + /// Mappings from source bytes to DOM positions + pub offset_map: Vec, +} + +/// Maps a source range to a position in the rendered DOM. +/// +/// # Example +/// +/// Source: `| foo | bar |` +/// Bytes: 0 2-5 7-10 12 +/// Chars: 0 2-5 7-10 12 (ASCII, so same) +/// +/// Rendered: +/// ```html +/// +/// +///
foobar
+/// ``` +/// +/// Mappings: +/// - `{ byte_range: 0..2, char_range: 0..2, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 0 }` - "| " invisible +/// - `{ byte_range: 2..5, char_range: 2..5, node_id: "t0-c0", char_offset_in_node: 0, utf16_len: 3 }` - "foo" visible +/// - `{ byte_range: 5..7, char_range: 5..7, node_id: "t0-c0", char_offset_in_node: 3, utf16_len: 0 }` - " |" invisible +/// - etc. +#[derive(Debug, Clone, PartialEq)] +pub struct OffsetMapping { + /// Source byte range (UTF-8 bytes, from parser) + pub byte_range: Range, + + /// Source char range (Unicode scalar values, for rope indexing) + pub char_range: Range, + + /// DOM node ID containing this content + /// For invisible content, this is the nearest visible container + pub node_id: String, + + /// Position within the node + /// - If child_index is Some: cursor at that child index in the element + /// - If child_index is None: UTF-16 offset in text content + pub char_offset_in_node: usize, + + /// If Some, position cursor at this child index in the element (not in text) + /// Used for positions after
or at empty lines + pub child_index: Option, + + /// Length of this mapping in UTF-16 chars in DOM + /// If 0, these source bytes aren't rendered (table pipes, etc) + pub utf16_len: usize, +} + +impl OffsetMapping { + /// Check if this mapping contains the given byte offset + pub fn contains_byte(&self, byte_offset: usize) -> bool { + self.byte_range.contains(&byte_offset) + } + + /// Check if this mapping contains the given char offset + pub fn contains_char(&self, char_offset: usize) -> bool { + self.char_range.contains(&char_offset) + } + + /// Check if this mapping represents invisible content + pub fn is_invisible(&self) -> bool { + self.utf16_len == 0 + } +} + +/// Find the offset mapping containing the given byte offset. +/// +/// Returns the mapping and whether the cursor should snap to the next +/// visible position (for invisible content). +pub fn find_mapping_for_byte( + offset_map: &[OffsetMapping], + byte_offset: usize, +) -> Option<(&OffsetMapping, bool)> { + // Binary search for the mapping + // Note: We allow cursor at the end boundary of a mapping (cursor after text) + let idx = offset_map + .binary_search_by(|mapping| { + if mapping.byte_range.end < byte_offset { + std::cmp::Ordering::Less + } else if mapping.byte_range.start > byte_offset { + std::cmp::Ordering::Greater + } else { + std::cmp::Ordering::Equal + } + }) + .ok()?; + + let mapping = &offset_map[idx]; + let should_snap = mapping.is_invisible(); + + Some((mapping, should_snap)) +} + +/// Find the offset mapping containing the given char offset. +/// +/// This is the primary lookup method for cursor restoration, since +/// cursor positions are tracked as char offsets in the rope. +/// +/// Returns the mapping and whether the cursor should snap to the next +/// visible position (for invisible content). +pub fn find_mapping_for_char( + offset_map: &[OffsetMapping], + char_offset: usize, +) -> Option<(&OffsetMapping, bool)> { + // Binary search for the mapping + // Rust ranges are end-exclusive, so range 0..10 covers positions 0-9. + // When cursor is exactly at a boundary (e.g., position 10 between 0..10 and 10..20), + // prefer the NEXT mapping so cursor goes "down" to new content. + let result = offset_map.binary_search_by(|mapping| { + if mapping.char_range.end <= char_offset { + // Cursor is at or after end of this mapping - look forward + std::cmp::Ordering::Less + } else if mapping.char_range.start > char_offset { + // Cursor is before this mapping + std::cmp::Ordering::Greater + } else { + // Cursor is within [start, end) + std::cmp::Ordering::Equal + } + }); + + let mapping = match result { + Ok(idx) => &offset_map[idx], + Err(idx) => { + // No exact match - cursor is at boundary between mappings (or past end) + // If cursor is exactly at end of previous mapping, return that mapping + // This handles cursor at end of document or end of last mapping + if idx > 0 && offset_map[idx - 1].char_range.end == char_offset { + &offset_map[idx - 1] + } else { + return None; + } + } + }; + + let should_snap = mapping.is_invisible(); + Some((mapping, should_snap)) +} + +/// Direction hint for cursor snapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SnapDirection { + Backward, + Forward, +} + +/// Result of finding a valid cursor position. +#[derive(Debug, Clone)] +pub struct SnappedPosition<'a> { + pub mapping: &'a OffsetMapping, + pub offset_in_mapping: usize, + pub snapped: Option, +} + +impl SnappedPosition<'_> { + /// Get the absolute char offset for this position. + pub fn char_offset(&self) -> usize { + self.mapping.char_range.start + self.offset_in_mapping + } +} + +/// Find the nearest valid cursor position to a char offset. +/// +/// A valid position is one that maps to visible content (utf16_len > 0). +/// If the position is already valid, returns it directly. Otherwise, +/// searches in the preferred direction first, falling back to the other +/// direction if needed. +pub fn find_nearest_valid_position( + offset_map: &[OffsetMapping], + char_offset: usize, + preferred_direction: Option, +) -> Option> { + if offset_map.is_empty() { + return None; + } + + // Try exact match first + if let Some((mapping, should_snap)) = find_mapping_for_char(offset_map, char_offset) { + if !should_snap { + // Position is valid, return it directly + let offset_in_mapping = char_offset.saturating_sub(mapping.char_range.start); + return Some(SnappedPosition { + mapping, + offset_in_mapping, + snapped: None, + }); + } + } + + // Position is invalid or not found - search for nearest valid + let search_order = match preferred_direction { + Some(SnapDirection::Backward) => [SnapDirection::Backward, SnapDirection::Forward], + Some(SnapDirection::Forward) | None => [SnapDirection::Forward, SnapDirection::Backward], + }; + + for direction in search_order { + if let Some(pos) = find_valid_in_direction(offset_map, char_offset, direction) { + return Some(pos); + } + } + + None +} + +/// Search for a valid position in a specific direction. +fn find_valid_in_direction( + offset_map: &[OffsetMapping], + char_offset: usize, + direction: SnapDirection, +) -> Option> { + match direction { + SnapDirection::Forward => { + // Find first visible mapping at or after char_offset + for mapping in offset_map { + if mapping.char_range.start >= char_offset && !mapping.is_invisible() { + return Some(SnappedPosition { + mapping, + offset_in_mapping: 0, + snapped: Some(SnapDirection::Forward), + }); + } + // Also check if char_offset falls within this visible mapping + if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { + let offset_in_mapping = char_offset - mapping.char_range.start; + return Some(SnappedPosition { + mapping, + offset_in_mapping, + snapped: Some(SnapDirection::Forward), + }); + } + } + None + } + SnapDirection::Backward => { + // Find last visible mapping at or before char_offset + for mapping in offset_map.iter().rev() { + if mapping.char_range.end <= char_offset && !mapping.is_invisible() { + // Snap to end of this mapping + let offset_in_mapping = mapping.char_range.len(); + return Some(SnappedPosition { + mapping, + offset_in_mapping, + snapped: Some(SnapDirection::Backward), + }); + } + // Also check if char_offset falls within this visible mapping + if mapping.char_range.contains(&char_offset) && !mapping.is_invisible() { + let offset_in_mapping = char_offset - mapping.char_range.start; + return Some(SnappedPosition { + mapping, + offset_in_mapping, + snapped: Some(SnapDirection::Backward), + }); + } + } + None + } + } +} + +/// Check if a char offset is at a valid (non-invisible) cursor position. +pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { + find_mapping_for_char(offset_map, char_offset) + .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_mapping_by_byte() { + let mappings = vec![ + OffsetMapping { + byte_range: 0..2, + char_range: 0..2, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 0, // invisible + }, + OffsetMapping { + byte_range: 2..5, + char_range: 2..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 3, + }, + OffsetMapping { + byte_range: 5..7, + char_range: 5..7, + node_id: "n0".to_string(), + char_offset_in_node: 3, + child_index: None, + utf16_len: 0, // invisible + }, + ]; + + // Byte 0 (invisible) + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 0).unwrap(); + assert_eq!(mapping.byte_range, 0..2); + assert!(should_snap); + + // Byte 3 (visible) + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 3).unwrap(); + assert_eq!(mapping.byte_range, 2..5); + assert!(!should_snap); + + // Byte 6 (invisible) + let (mapping, should_snap) = find_mapping_for_byte(&mappings, 6).unwrap(); + assert_eq!(mapping.byte_range, 5..7); + assert!(should_snap); + } + + #[test] + fn test_find_mapping_by_char() { + let mappings = vec![ + OffsetMapping { + byte_range: 0..2, + char_range: 0..2, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 0, // invisible + }, + OffsetMapping { + byte_range: 2..5, + char_range: 2..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 3, + }, + OffsetMapping { + byte_range: 5..7, + char_range: 5..7, + node_id: "n0".to_string(), + char_offset_in_node: 3, + child_index: None, + utf16_len: 0, // invisible + }, + ]; + + // Char 0 (invisible) + let (mapping, should_snap) = find_mapping_for_char(&mappings, 0).unwrap(); + assert_eq!(mapping.char_range, 0..2); + assert!(should_snap); + + // Char 3 (visible) + let (mapping, should_snap) = find_mapping_for_char(&mappings, 3).unwrap(); + assert_eq!(mapping.char_range, 2..5); + assert!(!should_snap); + + // Char 6 (invisible) + let (mapping, should_snap) = find_mapping_for_char(&mappings, 6).unwrap(); + assert_eq!(mapping.char_range, 5..7); + assert!(should_snap); + } + + #[test] + fn test_contains_byte() { + let mapping = OffsetMapping { + byte_range: 10..20, + char_range: 10..20, + node_id: "test".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 5, + }; + + assert!(!mapping.contains_byte(9)); + assert!(mapping.contains_byte(10)); + assert!(mapping.contains_byte(15)); + assert!(mapping.contains_byte(19)); + assert!(!mapping.contains_byte(20)); + } + + #[test] + fn test_contains_char() { + let mapping = OffsetMapping { + byte_range: 10..20, + char_range: 8..15, // emoji example: fewer chars than bytes + node_id: "test".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 5, + }; + + assert!(!mapping.contains_char(7)); + assert!(mapping.contains_char(8)); + assert!(mapping.contains_char(12)); + assert!(mapping.contains_char(14)); + assert!(!mapping.contains_char(15)); + } + + fn make_test_mappings() -> Vec { + vec![ + OffsetMapping { + byte_range: 0..2, + char_range: 0..2, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 0, // invisible: "![" + }, + OffsetMapping { + byte_range: 2..5, + char_range: 2..5, + node_id: "n0".to_string(), + char_offset_in_node: 0, + child_index: None, + utf16_len: 3, // visible: "alt" + }, + OffsetMapping { + byte_range: 5..15, + char_range: 5..15, + node_id: "n0".to_string(), + char_offset_in_node: 3, + child_index: None, + utf16_len: 0, // invisible: "](url.png)" + }, + OffsetMapping { + byte_range: 15..20, + char_range: 15..20, + node_id: "n0".to_string(), + char_offset_in_node: 3, + child_index: None, + utf16_len: 5, // visible: " text" + }, + ] + } + + #[test] + fn test_find_nearest_valid_position_exact_match() { + let mappings = make_test_mappings(); + + // Position 3 is in visible mapping (2..5) + let pos = find_nearest_valid_position(&mappings, 3, None).unwrap(); + assert_eq!(pos.char_offset(), 3); + assert!(pos.snapped.is_none()); + } + + #[test] + fn test_find_nearest_valid_position_snap_forward() { + let mappings = make_test_mappings(); + + // Position 0 is invisible, should snap forward to 2 + let pos = find_nearest_valid_position(&mappings, 0, Some(SnapDirection::Forward)).unwrap(); + assert_eq!(pos.char_offset(), 2); + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); + } + + #[test] + fn test_find_nearest_valid_position_snap_backward() { + let mappings = make_test_mappings(); + + // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5) + let pos = + find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); + assert_eq!(pos.char_offset(), 5); // end of "alt" mapping + assert_eq!(pos.snapped, Some(SnapDirection::Backward)); + } + + #[test] + fn test_find_nearest_valid_position_default_forward() { + let mappings = make_test_mappings(); + + // Position 0 is invisible, None direction defaults to forward + let pos = find_nearest_valid_position(&mappings, 0, None).unwrap(); + assert_eq!(pos.char_offset(), 2); + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); + } + + #[test] + fn test_find_nearest_valid_position_snap_forward_from_invisible() { + let mappings = make_test_mappings(); + + // Position 10 is in invisible range (5..15), forward finds visible (15..20) + let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Forward)).unwrap(); + assert_eq!(pos.char_offset(), 15); + assert_eq!(pos.snapped, Some(SnapDirection::Forward)); + } + + #[test] + fn test_is_valid_cursor_position() { + let mappings = make_test_mappings(); + + // Invisible positions + assert!(!is_valid_cursor_position(&mappings, 0)); + assert!(!is_valid_cursor_position(&mappings, 1)); + assert!(!is_valid_cursor_position(&mappings, 10)); + + // Visible positions + assert!(is_valid_cursor_position(&mappings, 2)); + assert!(is_valid_cursor_position(&mappings, 3)); + assert!(is_valid_cursor_position(&mappings, 4)); + assert!(is_valid_cursor_position(&mappings, 15)); + assert!(is_valid_cursor_position(&mappings, 17)); + } + + #[test] + fn test_find_nearest_valid_position_empty() { + let mappings: Vec = vec![]; + assert!(find_nearest_valid_position(&mappings, 0, None).is_none()); + } +} diff --git a/crates/weaver-editor-core/src/paragraph.rs b/crates/weaver-editor-core/src/paragraph.rs new file mode 100644 index 0000000..441c9a4 --- /dev/null +++ b/crates/weaver-editor-core/src/paragraph.rs @@ -0,0 +1,122 @@ +//! Paragraph-level rendering for incremental updates. +//! +//! Paragraphs are discovered during markdown rendering by tracking +//! Tag::Paragraph events. This allows updating only changed paragraphs in the DOM. + +use smol_str::{SmolStr, format_smolstr}; + +use crate::offset_map::OffsetMapping; +use crate::syntax::SyntaxSpanInfo; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::ops::Range; + +/// A rendered paragraph with its source range and offset mappings. +#[derive(Debug, Clone, PartialEq)] +pub struct ParagraphRender { + /// Stable content-based ID for DOM diffing (format: `p-{index}`) + pub id: SmolStr, + + /// Source byte range in the text buffer + pub byte_range: Range, + + /// Source char range in the text buffer + pub char_range: Range, + + /// Rendered HTML content (without wrapper div) + pub html: String, + + /// Offset mappings for this paragraph + pub offset_map: Vec, + + /// Syntax spans for conditional visibility + pub syntax_spans: Vec, + + /// Hash of source text for quick change detection + pub source_hash: u64, +} + +impl ParagraphRender { + /// Check if this paragraph contains a given byte offset. + pub fn contains_byte(&self, offset: usize) -> bool { + self.byte_range.contains(&offset) + } + + /// Check if this paragraph contains a given char offset. + pub fn contains_char(&self, offset: usize) -> bool { + self.char_range.contains(&offset) + } + + /// Get the length in chars. + pub fn char_len(&self) -> usize { + self.char_range.len() + } + + /// Get the length in bytes. + pub fn byte_len(&self) -> usize { + self.byte_range.len() + } +} + +/// Simple hash function for source text comparison. +/// +/// Used to quickly detect if paragraph content has changed. +pub fn hash_source(text: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + +/// Generate a paragraph ID from monotonic counter. +/// +/// IDs are stable across content changes - only position/cursor determines identity. +pub fn make_paragraph_id(index: usize) -> SmolStr { + format_smolstr!("p-{}", index) +} + +#[cfg(test)] +mod tests { + use smol_str::ToSmolStr; + + use super::*; + + #[test] + fn test_hash_source() { + let h1 = hash_source("hello world"); + let h2 = hash_source("hello world"); + let h3 = hash_source("hello world!"); + + assert_eq!(h1, h2); + assert_ne!(h1, h3); + } + + #[test] + fn test_make_paragraph_id() { + assert_eq!(make_paragraph_id(0), "p-0"); + assert_eq!(make_paragraph_id(42), "p-42"); + } + + #[test] + fn test_paragraph_contains() { + let para = ParagraphRender { + id: "p-0".to_smolstr(), + byte_range: 10..50, + char_range: 10..50, + html: String::new(), + offset_map: vec![], + syntax_spans: vec![], + source_hash: 0, + }; + + assert!(!para.contains_byte(9)); + assert!(para.contains_byte(10)); + assert!(para.contains_byte(25)); + assert!(para.contains_byte(49)); + assert!(!para.contains_byte(50)); + + assert!(!para.contains_char(9)); + assert!(para.contains_char(10)); + assert!(para.contains_char(25)); + assert!(!para.contains_char(50)); + } +} diff --git a/crates/weaver-editor-core/src/syntax.rs b/crates/weaver-editor-core/src/syntax.rs new file mode 100644 index 0000000..47ff71f --- /dev/null +++ b/crates/weaver-editor-core/src/syntax.rs @@ -0,0 +1,168 @@ +//! Syntax span tracking for conditional visibility. +//! +//! Tracks markdown syntax characters (like `**`, `#`, `>`) so they can be +//! shown/hidden based on cursor position (Obsidian-style editing). + +use std::ops::Range; + +use smol_str::SmolStr; + +/// Classification of markdown syntax characters. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyntaxType { + /// Inline formatting: **, *, ~~, `, $, [, ], (, ) + Inline, + /// Block formatting: #, >, -, *, 1., ```, --- + Block, +} + +/// Information about a syntax span for conditional visibility. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxSpanInfo { + /// Unique identifier for this syntax span (e.g., "s0", "s1") + pub syn_id: SmolStr, + /// Source char range this syntax covers (just this marker) + pub char_range: Range, + /// Whether this is inline or block-level syntax + pub syntax_type: SyntaxType, + /// For paired inline syntax (**, *, etc), the full formatted region + /// from opening marker through content to closing marker. + /// When cursor is anywhere in this range, the syntax is visible. + pub formatted_range: Option>, +} + +impl SyntaxSpanInfo { + /// Adjust all position fields by a character delta. + /// + /// This adjusts both `char_range` and `formatted_range` (if present) together, + /// ensuring they stay in sync. Use this instead of manually adjusting fields + /// to avoid forgetting one. + pub fn adjust_positions(&mut self, char_delta: isize) { + self.char_range.start = (self.char_range.start as isize + char_delta) as usize; + self.char_range.end = (self.char_range.end as isize + char_delta) as usize; + if let Some(ref mut fr) = self.formatted_range { + fr.start = (fr.start as isize + char_delta) as usize; + fr.end = (fr.end as isize + char_delta) as usize; + } + } + + /// Check if cursor is within the visibility range for this syntax. + /// + /// Returns true if the cursor should cause this syntax to be visible. + /// Uses `formatted_range` if present (for paired syntax like **bold**), + /// otherwise uses `char_range` (for standalone syntax like # heading). + pub fn cursor_in_range(&self, cursor_pos: usize) -> bool { + let range = self.formatted_range.as_ref().unwrap_or(&self.char_range); + cursor_pos >= range.start && cursor_pos <= range.end + } +} + +/// Classify syntax text as inline or block level. +pub fn classify_syntax(text: &str) -> SyntaxType { + let trimmed = text.trim_start(); + + // Check for block-level markers + if trimmed.starts_with('#') + || trimmed.starts_with('>') + || trimmed.starts_with("```") + || trimmed.starts_with("---") + || (trimmed.starts_with('-') + && trimmed + .chars() + .nth(1) + .map(|c| c.is_whitespace()) + .unwrap_or(false)) + || (trimmed.starts_with('*') + && trimmed + .chars() + .nth(1) + .map(|c| c.is_whitespace()) + .unwrap_or(false)) + || trimmed + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + && trimmed.contains('.') + { + SyntaxType::Block + } else { + SyntaxType::Inline + } +} + +#[cfg(test)] +mod tests { + use smol_str::ToSmolStr; + + use super::*; + + #[test] + fn test_classify_block_syntax() { + assert_eq!(classify_syntax("# "), SyntaxType::Block); + assert_eq!(classify_syntax("## "), SyntaxType::Block); + assert_eq!(classify_syntax("> "), SyntaxType::Block); + assert_eq!(classify_syntax("- "), SyntaxType::Block); + assert_eq!(classify_syntax("* "), SyntaxType::Block); + assert_eq!(classify_syntax("1. "), SyntaxType::Block); + assert_eq!(classify_syntax("```"), SyntaxType::Block); + assert_eq!(classify_syntax("---"), SyntaxType::Block); + } + + #[test] + fn test_classify_inline_syntax() { + assert_eq!(classify_syntax("**"), SyntaxType::Inline); + assert_eq!(classify_syntax("*"), SyntaxType::Inline); + assert_eq!(classify_syntax("`"), SyntaxType::Inline); + assert_eq!(classify_syntax("~~"), SyntaxType::Inline); + assert_eq!(classify_syntax("["), SyntaxType::Inline); + assert_eq!(classify_syntax("]("), SyntaxType::Inline); + } + + #[test] + fn test_adjust_positions() { + let mut span = SyntaxSpanInfo { + syn_id: "s0".to_smolstr(), + char_range: 10..15, + syntax_type: SyntaxType::Inline, + formatted_range: Some(10..25), + }; + + span.adjust_positions(5); + assert_eq!(span.char_range, 15..20); + assert_eq!(span.formatted_range, Some(15..30)); + + span.adjust_positions(-3); + assert_eq!(span.char_range, 12..17); + assert_eq!(span.formatted_range, Some(12..27)); + } + + #[test] + fn test_cursor_in_range() { + // Paired syntax with formatted_range + let span = SyntaxSpanInfo { + syn_id: "s0".to_smolstr(), + char_range: 0..2, // ** + syntax_type: SyntaxType::Inline, + formatted_range: Some(0..10), // **content** + }; + + assert!(span.cursor_in_range(0)); + assert!(span.cursor_in_range(5)); + assert!(span.cursor_in_range(10)); + assert!(!span.cursor_in_range(11)); + + // Unpaired syntax without formatted_range + let span = SyntaxSpanInfo { + syn_id: "s1".to_smolstr(), + char_range: 0..2, // ## + syntax_type: SyntaxType::Block, + formatted_range: None, + }; + + assert!(span.cursor_in_range(0)); + assert!(span.cursor_in_range(1)); + assert!(span.cursor_in_range(2)); + assert!(!span.cursor_in_range(3)); + } +} diff --git a/crates/weaver-editor-core/src/text.rs b/crates/weaver-editor-core/src/text.rs new file mode 100644 index 0000000..36f538b --- /dev/null +++ b/crates/weaver-editor-core/src/text.rs @@ -0,0 +1,203 @@ +//! Text buffer abstraction for editor storage. +//! +//! The `TextBuffer` trait provides a common interface for text storage, +//! allowing the editor to work with different backends (ropey for local, +//! Loro for CRDT collaboration). + +use smol_str::{SmolStr, ToSmolStr}; +use std::ops::Range; + +/// A text buffer that supports efficient editing and offset conversion. +/// +/// All offsets are in Unicode scalar values (chars), not bytes or UTF-16. +pub trait TextBuffer: Default + Clone { + /// Total length in bytes (UTF-8). + fn len_bytes(&self) -> usize; + + /// Total length in chars (Unicode scalar values). + fn len_chars(&self) -> usize; + + /// Check if empty. + fn is_empty(&self) -> bool { + self.len_chars() == 0 + } + + /// Insert text at char offset. + fn insert(&mut self, char_offset: usize, text: &str); + + /// Delete char range. + fn delete(&mut self, char_range: Range); + + /// Replace char range with text. + fn replace(&mut self, char_range: Range, text: &str) { + self.delete(char_range.clone()); + self.insert(char_range.start, text); + } + + /// Get a slice as SmolStr. Returns None if range is invalid. + /// + /// SmolStr is used for efficiency: strings ≤23 bytes are stored inline + /// (no heap allocation), longer strings are Arc'd (cheap to clone). + fn slice(&self, char_range: Range) -> Option; + + /// Get character at offset. Returns None if out of bounds. + fn char_at(&self, char_offset: usize) -> Option; + + /// Convert entire buffer to String. + fn to_string(&self) -> String; + + /// Convert char offset to byte offset. + fn char_to_byte(&self, char_offset: usize) -> usize; + + /// Convert byte offset to char offset. + fn byte_to_char(&self, byte_offset: usize) -> usize; +} + +/// Ropey-backed text buffer for local editing. +/// +/// Provides O(log n) editing operations and offset conversions. +#[derive(Clone, Default)] +pub struct EditorRope { + rope: ropey::Rope, +} + +impl EditorRope { + /// Create a new empty rope. + pub fn new() -> Self { + Self::default() + } + + /// Create from string. + pub fn from_str(s: &str) -> Self { + Self { + rope: ropey::Rope::from_str(s), + } + } + + /// Get a reference to the underlying rope (for advanced operations). + pub fn rope(&self) -> &ropey::Rope { + &self.rope + } + + /// Get a rope slice for zero-copy iteration over chunks. + /// + /// Use this when you need to iterate over the text without allocating, + /// e.g., for hashing or character-by-character processing. + pub fn rope_slice(&self, char_range: Range) -> Option> { + if char_range.end > self.rope.len_chars() { + return None; + } + Some(self.rope.slice(char_range)) + } +} + +impl TextBuffer for EditorRope { + fn len_bytes(&self) -> usize { + self.rope.len_bytes() + } + + fn len_chars(&self) -> usize { + self.rope.len_chars() + } + + fn insert(&mut self, char_offset: usize, text: &str) { + self.rope.insert(char_offset, text); + } + + fn delete(&mut self, char_range: Range) { + self.rope.remove(char_range); + } + + fn slice(&self, char_range: Range) -> Option { + if char_range.end > self.len_chars() { + return None; + } + Some(self.rope.slice(char_range).to_smolstr()) + } + + fn char_at(&self, char_offset: usize) -> Option { + if char_offset >= self.len_chars() { + return None; + } + Some(self.rope.char(char_offset)) + } + + fn to_string(&self) -> String { + self.rope.to_string() + } + + fn char_to_byte(&self, char_offset: usize) -> usize { + self.rope.char_to_byte(char_offset) + } + + fn byte_to_char(&self, byte_offset: usize) -> usize { + self.rope.byte_to_char(byte_offset) + } +} + +impl From<&str> for EditorRope { + fn from(s: &str) -> Self { + Self::from_str(s) + } +} + +impl From for EditorRope { + fn from(s: String) -> Self { + Self::from_str(&s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_operations() { + let mut rope = EditorRope::from_str("hello world"); + assert_eq!(rope.len_chars(), 11); + assert_eq!(rope.to_string(), "hello world"); + + rope.insert(5, " beautiful"); + assert_eq!(rope.to_string(), "hello beautiful world"); + + // " beautiful" is 10 chars at positions 5..15 + rope.delete(5..15); + assert_eq!(rope.to_string(), "hello world"); + } + + #[test] + fn test_char_at() { + let rope = EditorRope::from_str("hello"); + assert_eq!(rope.char_at(0), Some('h')); + assert_eq!(rope.char_at(4), Some('o')); + assert_eq!(rope.char_at(5), None); + } + + #[test] + fn test_slice() { + let rope = EditorRope::from_str("hello world"); + assert_eq!(rope.slice(0..5).as_deref(), Some("hello")); + assert_eq!(rope.slice(6..11).as_deref(), Some("world")); + assert_eq!(rope.slice(0..100), None); + } + + #[test] + fn test_offset_conversion() { + // "hello 🌍" - emoji is 4 bytes, 1 char + let rope = EditorRope::from_str("hello 🌍"); + assert_eq!(rope.len_chars(), 7); // h e l l o 🌍 + assert_eq!(rope.len_bytes(), 10); // 6 + 4 + + assert_eq!(rope.char_to_byte(6), 6); // before emoji + assert_eq!(rope.char_to_byte(7), 10); // after emoji + assert_eq!(rope.byte_to_char(6), 6); + assert_eq!(rope.byte_to_char(10), 7); + } + + #[test] + fn test_replace() { + let mut rope = EditorRope::from_str("hello world"); + rope.replace(6..11, "rust"); + assert_eq!(rope.to_string(), "hello rust"); + } +} diff --git a/crates/weaver-editor-core/src/types.rs b/crates/weaver-editor-core/src/types.rs new file mode 100644 index 0000000..ce44eff --- /dev/null +++ b/crates/weaver-editor-core/src/types.rs @@ -0,0 +1,319 @@ +//! Core editor types: cursor, selection, composition, and edit tracking. +//! +//! These types are framework-agnostic and can be used with any text buffer implementation. + +use std::ops::Range; +use web_time::Instant; + +/// Cursor state including position and affinity. +#[derive(Clone, Debug, Copy, PartialEq, Eq)] +pub struct CursorState { + /// Character offset in text (NOT byte offset!) + pub offset: usize, + + /// Prefer left/right when at boundary (for vertical cursor movement) + pub affinity: Affinity, +} + +impl Default for CursorState { + fn default() -> Self { + Self { + offset: 0, + affinity: Affinity::Before, + } + } +} + +impl CursorState { + /// Create a new cursor at the given offset. + pub fn new(offset: usize) -> Self { + Self { + offset, + affinity: Affinity::Before, + } + } + + /// Create a cursor with specific affinity. + pub fn with_affinity(offset: usize, affinity: Affinity) -> Self { + Self { offset, affinity } + } +} + +/// Cursor affinity for vertical movement. +/// +/// When navigating vertically, the cursor needs to know which side of a line +/// break it prefers. `Before` means stick to the end of the previous line, +/// `After` means stick to the start of the next line. +#[derive(Clone, Debug, Copy, PartialEq, Eq, Default)] +pub enum Affinity { + #[default] + Before, + After, +} + +/// Text selection with anchor and head positions. +/// +/// The anchor is where the selection started, the head is where the cursor is now. +/// They may be in any order - use `start()` and `end()` for ordered bounds. +#[derive(Clone, Debug, Copy, PartialEq, Eq)] +pub struct Selection { + /// Where selection started + pub anchor: usize, + /// Where cursor is now + pub head: usize, +} + +impl Selection { + /// Create a new selection. + pub fn new(anchor: usize, head: usize) -> Self { + Self { anchor, head } + } + + /// Create a collapsed selection (cursor position). + pub fn collapsed(offset: usize) -> Self { + Self { + anchor: offset, + head: offset, + } + } + + /// Get the start (lower bound) of the selection. + pub fn start(&self) -> usize { + self.anchor.min(self.head) + } + + /// Get the end (upper bound) of the selection. + pub fn end(&self) -> usize { + self.anchor.max(self.head) + } + + /// Check if the selection is collapsed (empty, cursor only). + pub fn is_collapsed(&self) -> bool { + self.anchor == self.head + } + + /// Check if an offset is within the selection. + pub fn contains(&self, offset: usize) -> bool { + offset >= self.start() && offset < self.end() + } + + /// Get the selection length. + pub fn len(&self) -> usize { + self.end() - self.start() + } + + /// Check if empty (same as is_collapsed). + pub fn is_empty(&self) -> bool { + self.is_collapsed() + } + + /// Convert to a Range (ordered). + pub fn to_range(&self) -> Range { + self.start()..self.end() + } + + /// Check if the selection is backwards (head before anchor). + pub fn is_backwards(&self) -> bool { + self.head < self.anchor + } +} + +/// IME composition state (for international text input). +/// +/// During IME composition, the user is building up a string of characters +/// that hasn't been committed yet. This tracks where that composition +/// started and what text is currently being composed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompositionState { + /// Character offset where composition started + pub start_offset: usize, + /// Current composition text (uncommitted) + pub text: String, +} + +impl CompositionState { + /// Create a new composition state. + pub fn new(start_offset: usize, text: String) -> Self { + Self { start_offset, text } + } + + /// Get the end offset of the composition. + pub fn end_offset(&self) -> usize { + self.start_offset + self.text.chars().count() + } + + /// Check if an offset is within the composition. + pub fn contains(&self, offset: usize) -> bool { + offset >= self.start_offset && offset < self.end_offset() + } +} + +/// Information about the most recent edit, used for incremental rendering optimization. +/// +/// This tracks enough information to determine which paragraphs need re-rendering +/// after an edit, enabling efficient incremental updates instead of full re-renders. +#[derive(Clone, Debug)] +pub struct EditInfo { + /// Character offset where the edit occurred + pub edit_char_pos: usize, + /// Number of characters inserted + pub inserted_len: usize, + /// Number of characters deleted + pub deleted_len: usize, + /// Whether the edit contains a newline (boundary-affecting) + pub contains_newline: bool, + /// Whether the edit is in the block-syntax zone of a line (first ~6 chars). + /// Edits here could affect block-level syntax like headings, lists, code fences. + pub in_block_syntax_zone: bool, + /// Document length (in chars) after this edit was applied. + /// Used to detect stale edit info - if current doc length doesn't match, + /// the edit info is from a previous render cycle and shouldn't be used. + pub doc_len_after: usize, + /// When this edit occurred. Used for idle detection in collaborative sync. + pub timestamp: Instant, +} + +impl PartialEq for EditInfo { + fn eq(&self, other: &Self) -> bool { + // Compare all fields except timestamp (not meaningful for equality) + self.edit_char_pos == other.edit_char_pos + && self.inserted_len == other.inserted_len + && self.deleted_len == other.deleted_len + && self.contains_newline == other.contains_newline + && self.in_block_syntax_zone == other.in_block_syntax_zone + && self.doc_len_after == other.doc_len_after + } +} + +impl EditInfo { + /// Check if this edit info is stale (doc has changed since this edit). + pub fn is_stale(&self, current_doc_len: usize) -> bool { + self.doc_len_after != current_doc_len + } + + /// Check if this edit might affect paragraph boundaries. + pub fn affects_boundaries(&self) -> bool { + self.contains_newline || self.in_block_syntax_zone + } + + /// Get the range that was affected by this edit. + /// + /// For insertions: the range of inserted text. + /// For deletions: an empty range at the deletion point. + /// For replacements: the range of inserted text. + pub fn affected_range(&self) -> Range { + self.edit_char_pos..self.edit_char_pos + self.inserted_len + } +} + +/// Max distance from line start where block syntax can appear. +/// Covers: `######` (6), ```` ``` ```` (3), `> ` (2), `- ` (2), `999. ` (5) +pub const BLOCK_SYNTAX_ZONE: usize = 6; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_selection_bounds() { + // Forward selection + let sel = Selection::new(5, 10); + assert_eq!(sel.start(), 5); + assert_eq!(sel.end(), 10); + assert!(!sel.is_backwards()); + + // Backward selection + let sel = Selection::new(10, 5); + assert_eq!(sel.start(), 5); + assert_eq!(sel.end(), 10); + assert!(sel.is_backwards()); + } + + #[test] + fn test_selection_collapsed() { + let sel = Selection::collapsed(7); + assert!(sel.is_collapsed()); + assert!(sel.is_empty()); + assert_eq!(sel.len(), 0); + assert_eq!(sel.start(), 7); + assert_eq!(sel.end(), 7); + } + + #[test] + fn test_selection_contains() { + let sel = Selection::new(5, 10); + assert!(!sel.contains(4)); + assert!(sel.contains(5)); + assert!(sel.contains(7)); + assert!(sel.contains(9)); + assert!(!sel.contains(10)); // end is exclusive + } + + #[test] + fn test_selection_to_range() { + let sel = Selection::new(10, 5); + assert_eq!(sel.to_range(), 5..10); + } + + #[test] + fn test_composition_contains() { + let comp = CompositionState::new(10, "你好".to_string()); + assert_eq!(comp.end_offset(), 12); // 2 chars + assert!(!comp.contains(9)); + assert!(comp.contains(10)); + assert!(comp.contains(11)); + assert!(!comp.contains(12)); // end is exclusive + } + + #[test] + fn test_edit_info_stale() { + let edit = EditInfo { + edit_char_pos: 5, + inserted_len: 3, + deleted_len: 0, + contains_newline: false, + in_block_syntax_zone: false, + doc_len_after: 100, + timestamp: Instant::now(), + }; + + assert!(!edit.is_stale(100)); + assert!(edit.is_stale(101)); + } + + #[test] + fn test_edit_info_affects_boundaries() { + let edit = EditInfo { + edit_char_pos: 0, + inserted_len: 1, + deleted_len: 0, + contains_newline: false, + in_block_syntax_zone: true, + doc_len_after: 100, + timestamp: Instant::now(), + }; + assert!(edit.affects_boundaries()); + + let edit = EditInfo { + edit_char_pos: 50, + inserted_len: 1, + deleted_len: 0, + contains_newline: true, + in_block_syntax_zone: false, + doc_len_after: 100, + timestamp: Instant::now(), + }; + assert!(edit.affects_boundaries()); + + let edit = EditInfo { + edit_char_pos: 50, + inserted_len: 1, + deleted_len: 0, + contains_newline: false, + in_block_syntax_zone: false, + doc_len_after: 100, + timestamp: Instant::now(), + }; + assert!(!edit.affects_boundaries()); + } +} diff --git a/crates/weaver-editor-core/src/visibility.rs b/crates/weaver-editor-core/src/visibility.rs new file mode 100644 index 0000000..5b333eb --- /dev/null +++ b/crates/weaver-editor-core/src/visibility.rs @@ -0,0 +1,396 @@ +//! Conditional syntax visibility based on cursor position. +//! +//! Implements Obsidian-style formatting character visibility: syntax markers +//! are hidden when cursor is not near them, revealed when cursor approaches. + +use smol_str::SmolStr; + +use crate::paragraph::ParagraphRender; +use crate::syntax::{SyntaxSpanInfo, SyntaxType}; +use crate::types::Selection; +use std::collections::HashSet; +use std::ops::Range; + +/// Determines which syntax spans should be visible based on cursor/selection. +#[derive(Debug, Clone, Default)] +pub struct VisibilityState { + /// Set of syn_ids that should be visible + pub visible_span_ids: HashSet, +} + +impl VisibilityState { + /// Calculate visibility based on cursor position and selection. + pub fn calculate( + cursor_offset: usize, + selection: Option<&Selection>, + syntax_spans: &[SyntaxSpanInfo], + paragraphs: &[ParagraphRender], + ) -> Self { + let mut visible = HashSet::new(); + + for span in syntax_spans { + // Find the paragraph containing this span for boundary clamping + let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs); + + let should_show = match span.syntax_type { + SyntaxType::Inline => { + // Show if cursor within formatted span content OR adjacent to markers + // "Adjacent" means within 1 char of the syntax boundaries, + // clamped to paragraph bounds (paragraphs are split by newlines, + // so clamping to para bounds prevents cross-line extension) + let extended_start = + safe_extend_left(span.char_range.start, 1, para_bounds.as_ref()); + let extended_end = + safe_extend_right(span.char_range.end, 1, para_bounds.as_ref()); + let extended_range = extended_start..extended_end; + + // Also show if cursor is anywhere in the formatted_range + // (the region between paired opening/closing markers) + // Extend by 1 char on BOTH sides for symmetric "approaching" behavior, + // clamped to paragraph bounds. + let in_formatted_region = span + .formatted_range + .as_ref() + .map(|r| { + let ext_start = safe_extend_left(r.start, 1, para_bounds.as_ref()); + let ext_end = safe_extend_right(r.end, 1, para_bounds.as_ref()); + cursor_offset >= ext_start && cursor_offset <= ext_end + }) + .unwrap_or(false); + + let in_extended = extended_range.contains(&cursor_offset); + in_extended + || in_formatted_region + || selection_overlaps(selection, &span.char_range) + || span + .formatted_range + .as_ref() + .map(|r| selection_overlaps(selection, r)) + .unwrap_or(false) + } + SyntaxType::Block => { + // Show if cursor anywhere in same paragraph (with slop for edge cases) + // The slop handles typing at the end of a heading like "# |" + let para_bounds = find_paragraph_bounds(&span.char_range, paragraphs); + let in_paragraph = para_bounds + .as_ref() + .map(|p| { + // Extend paragraph bounds by 1 char on each side for slop + let ext_start = p.start.saturating_sub(1); + let ext_end = p.end.saturating_add(1); + cursor_offset >= ext_start && cursor_offset <= ext_end + }) + .unwrap_or(false); + + in_paragraph || selection_overlaps(selection, &span.char_range) + } + }; + + if should_show { + visible.insert(span.syn_id.clone()); + } + } + + tracing::debug!( + target: "weaver::visibility", + cursor_offset, + total_spans = syntax_spans.len(), + visible_count = visible.len(), + "calculated visibility" + ); + + Self { + visible_span_ids: visible, + } + } + + /// Check if a specific span should be visible. + pub fn is_visible(&self, syn_id: &str) -> bool { + self.visible_span_ids.contains(syn_id) + } + + /// Get the number of visible spans. + pub fn visible_count(&self) -> usize { + self.visible_span_ids.len() + } + + /// Check if any spans are visible. + pub fn has_visible(&self) -> bool { + !self.visible_span_ids.is_empty() + } +} + +/// Check if selection overlaps with a char range. +fn selection_overlaps(selection: Option<&Selection>, range: &Range) -> bool { + let Some(sel) = selection else { + return false; + }; + + let sel_start = sel.start(); + let sel_end = sel.end(); + + // Check if ranges overlap + sel_start < range.end && sel_end > range.start +} + +/// Find the paragraph bounds containing a syntax span. +fn find_paragraph_bounds( + syntax_range: &Range, + paragraphs: &[ParagraphRender], +) -> Option> { + for para in paragraphs { + // Skip gap paragraphs + if para.syntax_spans.is_empty() && !para.char_range.is_empty() { + continue; + } + + if para.char_range.start <= syntax_range.start && syntax_range.end <= para.char_range.end { + return Some(para.char_range.clone()); + } + } + None +} + +/// Safely extend a position leftward by `amount` chars, clamped to paragraph bounds. +/// +/// Paragraphs are already split by newlines, so clamping to paragraph bounds +/// naturally prevents extending across line boundaries. +fn safe_extend_left(pos: usize, amount: usize, para_bounds: Option<&Range>) -> usize { + let min_pos = para_bounds.map(|p| p.start).unwrap_or(0); + pos.saturating_sub(amount).max(min_pos) +} + +/// Safely extend a position rightward by `amount` chars, clamped to paragraph bounds. +/// +/// Paragraphs are already split by newlines, so clamping to paragraph bounds +/// naturally prevents extending across line boundaries. +fn safe_extend_right(pos: usize, amount: usize, para_bounds: Option<&Range>) -> usize { + let max_pos = para_bounds.map(|p| p.end).unwrap_or(usize::MAX); + pos.saturating_add(amount).min(max_pos) +} + +#[cfg(test)] +mod tests { + use smol_str::{ToSmolStr, format_smolstr}; + + use super::*; + + fn make_span( + syn_id: &str, + start: usize, + end: usize, + syntax_type: SyntaxType, + ) -> SyntaxSpanInfo { + SyntaxSpanInfo { + syn_id: syn_id.to_smolstr(), + char_range: start..end, + syntax_type, + formatted_range: None, + } + } + + fn make_span_with_range( + syn_id: &str, + start: usize, + end: usize, + syntax_type: SyntaxType, + formatted_range: Range, + ) -> SyntaxSpanInfo { + SyntaxSpanInfo { + syn_id: syn_id.to_smolstr(), + char_range: start..end, + syntax_type, + formatted_range: Some(formatted_range), + } + } + + fn make_para(start: usize, end: usize, syntax_spans: Vec) -> ParagraphRender { + ParagraphRender { + id: format_smolstr!("test-{}-{}", start, end), + byte_range: start..end, + char_range: start..end, + html: String::new(), + offset_map: vec![], + syntax_spans, + source_hash: 0, + } + } + + #[test] + fn test_inline_visibility_cursor_inside() { + // **bold** at chars 0-2 (opening **) and 6-8 (closing **) + // Text positions: 0-1 = **, 2-5 = bold, 6-7 = ** + // formatted_range is 0..8 (the whole **bold** region) + let spans = vec![ + make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening ** + make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** + ]; + let paras = vec![make_para(0, 8, spans.clone())]; + + // Cursor at position 4 (middle of "bold", inside formatted region) + let vis = VisibilityState::calculate(4, None, &spans, ¶s); + assert!( + vis.is_visible("s0"), + "opening ** should be visible when cursor inside formatted region" + ); + assert!( + vis.is_visible("s1"), + "closing ** should be visible when cursor inside formatted region" + ); + + // Cursor at position 2 (adjacent to opening **, start of "bold") + let vis = VisibilityState::calculate(2, None, &spans, ¶s); + assert!( + vis.is_visible("s0"), + "opening ** should be visible when cursor adjacent at start of bold" + ); + + // Cursor at position 5 (adjacent to closing **, end of "bold") + let vis = VisibilityState::calculate(5, None, &spans, ¶s); + assert!( + vis.is_visible("s1"), + "closing ** should be visible when cursor adjacent at end of bold" + ); + } + + #[test] + fn test_inline_visibility_without_formatted_range() { + // Test without formatted_range - just adjacency-based visibility + let spans = vec![ + make_span("s0", 0, 2, SyntaxType::Inline), // opening ** (no formatted_range) + make_span("s1", 6, 8, SyntaxType::Inline), // closing ** (no formatted_range) + ]; + let paras = vec![make_para(0, 8, spans.clone())]; + + // Cursor at position 4 (middle of "bold", not adjacent to either marker) + let vis = VisibilityState::calculate(4, None, &spans, ¶s); + assert!( + !vis.is_visible("s0"), + "opening ** should be hidden when no formatted_range and cursor not adjacent" + ); + assert!( + !vis.is_visible("s1"), + "closing ** should be hidden when no formatted_range and cursor not adjacent" + ); + } + + #[test] + fn test_inline_visibility_cursor_adjacent() { + // "test **bold** after" + // 5 7 + let spans = vec![ + make_span("s0", 5, 7, SyntaxType::Inline), // ** at positions 5-6 + ]; + let paras = vec![make_para(0, 19, spans.clone())]; + + // Cursor at position 4 (one before ** which starts at 5) + let vis = VisibilityState::calculate(4, None, &spans, ¶s); + assert!( + vis.is_visible("s0"), + "** should be visible when cursor adjacent" + ); + + // Cursor at position 7 (one after ** which ends at 6, since range is exclusive) + let vis = VisibilityState::calculate(7, None, &spans, ¶s); + assert!( + vis.is_visible("s0"), + "** should be visible when cursor adjacent after span" + ); + } + + #[test] + fn test_inline_visibility_cursor_far() { + let spans = vec![make_span("s0", 10, 12, SyntaxType::Inline)]; + let paras = vec![make_para(0, 33, spans.clone())]; + + // Cursor at position 0 (far from **) + let vis = VisibilityState::calculate(0, None, &spans, ¶s); + assert!( + !vis.is_visible("s0"), + "** should be hidden when cursor far away" + ); + } + + #[test] + fn test_block_visibility_same_paragraph() { + // # at start of heading + let spans = vec![ + make_span("s0", 0, 2, SyntaxType::Block), // "# " + ]; + let paras = vec![ + make_para(0, 10, spans.clone()), // heading paragraph + make_para(12, 30, vec![]), // next paragraph + ]; + + // Cursor at position 5 (inside heading) + let vis = VisibilityState::calculate(5, None, &spans, ¶s); + assert!( + vis.is_visible("s0"), + "# should be visible when cursor in same paragraph" + ); + } + + #[test] + fn test_block_visibility_different_paragraph() { + let spans = vec![make_span("s0", 0, 2, SyntaxType::Block)]; + let paras = vec![make_para(0, 10, spans.clone()), make_para(12, 30, vec![])]; + + // Cursor at position 20 (in second paragraph) + let vis = VisibilityState::calculate(20, None, &spans, ¶s); + assert!( + !vis.is_visible("s0"), + "# should be hidden when cursor in different paragraph" + ); + } + + #[test] + fn test_selection_reveals_syntax() { + let spans = vec![make_span("s0", 5, 7, SyntaxType::Inline)]; + let paras = vec![make_para(0, 24, spans.clone())]; + + // Selection overlaps the syntax span + let selection = Selection::new(3, 10); + let vis = VisibilityState::calculate(10, Some(&selection), &spans, ¶s); + assert!( + vis.is_visible("s0"), + "** should be visible when selection overlaps" + ); + } + + #[test] + fn test_paragraph_boundary_blocks_extension() { + // Cursor in paragraph 2 should NOT reveal syntax in paragraph 1, + // even if cursor is only 1 char after the paragraph boundary + // (paragraph bounds clamp the extension) + let spans = vec![ + make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8), // opening ** + make_span_with_range("s1", 6, 8, SyntaxType::Inline, 0..8), // closing ** + ]; + let paras = vec![ + make_para(0, 8, spans.clone()), // "**bold**" + make_para(9, 13, vec![]), // "text" (after newline) + ]; + + // Cursor at position 9 (start of second paragraph) + // Should NOT reveal the closing ** because para bounds clamp extension + let vis = VisibilityState::calculate(9, None, &spans, ¶s); + assert!( + !vis.is_visible("s1"), + "closing ** should NOT be visible when cursor is in next paragraph" + ); + } + + #[test] + fn test_extension_clamps_to_paragraph() { + // Syntax at very start of paragraph - extension left should stop at para start + let spans = vec![make_span_with_range("s0", 0, 2, SyntaxType::Inline, 0..8)]; + let paras = vec![make_para(0, 8, spans.clone())]; + + // Cursor at position 0 - should still see the opening ** + let vis = VisibilityState::calculate(0, None, &spans, ¶s); + assert!( + vis.is_visible("s0"), + "** at start should be visible when cursor at position 0" + ); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index beb9f66..e250eaa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,32 @@ services: + # ClickHouse - analytics database (internal only, no host ports exposed) + clickhouse: + image: clickhouse/clickhouse-server:25.11 + container_name: weaver-clickhouse + # No ports exposed to host - only accessible via docker network + ports: + - "8123:8123" + - "9000:9000" + volumes: + - ~/data/clickhouse:/var/lib/clickhouse + - ~/data/clickhouse-logs:/var/log/clickhouse-server + - ~/data/clickhouse-config:/etc/clickhouse-server/config.d + environment: + CLICKHOUSE_DB: ${CLICKHOUSE_DATABASE:-weaver} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"] + interval: 10s + timeout: 5s + retries: 3 + restart: unless-stopped + # Docker registry for local image hosting registry: image: registry:2 @@ -45,9 +73,9 @@ services: - index_data:/app/data environment: RUST_LOG: info,weaver_index=debug,hyper_util::client::legacy::pool=info - CLICKHOUSE_URL: ${CLICKHOUSE_URL} + CLICKHOUSE_URL: http://clickhouse:8123 CLICKHOUSE_DATABASE: ${CLICKHOUSE_DATABASE:-weaver} - CLICKHOUSE_USER: ${CLICKHOUSE_USER} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} INDEXER_SOURCE: tap TAP_URL: ws://tap:2480/channel @@ -57,6 +85,8 @@ services: depends_on: tap: condition: service_healthy + clickhouse: + condition: service_healthy healthcheck: test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:3000/xrpc/_health"] interval: 20s