further pulls out of the app crate

Orual 735b7880 9b12c555

+1409 -7
+1
Cargo.lock
··· 12197 12197 "smol_str", 12198 12198 "tracing", 12199 12199 "wasm-bindgen", 12200 + "wasm-bindgen-futures", 12200 12201 "wasm-bindgen-test", 12201 12202 "weaver-editor-core", 12202 12203 "web-sys",
+7
crates/weaver-editor-browser/Cargo.toml
··· 18 18 # Logging 19 19 tracing = { workspace = true } 20 20 21 + # Async (for JS promise bridging) 22 + wasm-bindgen-futures = "0.4" 23 + 21 24 # Utilities 22 25 smol_str = "0.3" 23 26 ··· 48 51 "DataTransferItemList", 49 52 "FocusEvent", 50 53 "MouseEvent", 54 + "Blob", 55 + "BlobPropertyBag", 56 + "Clipboard", 57 + "ClipboardItem", 51 58 ] 52 59 53 60 [features]
+254 -2
crates/weaver-editor-browser/src/events.rs
··· 1 1 //! Browser event handling for the editor. 2 2 //! 3 - //! Handles beforeinput, keydown, paste, and other DOM events. 3 + //! Provides browser-specific event extraction and input type parsing for 4 + //! the `beforeinput` event and other DOM events. 4 5 5 - // TODO: Migrate from weaver-app (beforeinput.rs, input.rs) 6 + use wasm_bindgen::prelude::*; 7 + use weaver_editor_core::{InputType, OffsetMapping, Range}; 8 + 9 + use crate::dom_sync::dom_position_to_text_offset; 10 + 11 + // === StaticRange binding === 12 + // 13 + // Custom wasm_bindgen binding for StaticRange since web-sys doesn't expose it. 14 + // StaticRange is returned by InputEvent.getTargetRanges() and represents 15 + // a fixed range that doesn't update when the DOM changes. 16 + 17 + #[wasm_bindgen] 18 + extern "C" { 19 + /// The StaticRange interface represents a static range of text in the DOM. 20 + pub type StaticRange; 21 + 22 + #[wasm_bindgen(method, getter, structural)] 23 + pub fn startContainer(this: &StaticRange) -> web_sys::Node; 24 + 25 + #[wasm_bindgen(method, getter, structural)] 26 + pub fn startOffset(this: &StaticRange) -> u32; 27 + 28 + #[wasm_bindgen(method, getter, structural)] 29 + pub fn endContainer(this: &StaticRange) -> web_sys::Node; 30 + 31 + #[wasm_bindgen(method, getter, structural)] 32 + pub fn endOffset(this: &StaticRange) -> u32; 33 + 34 + #[wasm_bindgen(method, getter, structural)] 35 + pub fn collapsed(this: &StaticRange) -> bool; 36 + } 37 + 38 + // === InputType browser parsing === 39 + 40 + /// Parse a browser inputType string to an InputType enum. 41 + /// 42 + /// This handles the W3C Input Events inputType values as returned by 43 + /// `InputEvent.inputType` in browsers. 44 + pub fn parse_browser_input_type(s: &str) -> InputType { 45 + match s { 46 + // Insertion 47 + "insertText" => InputType::InsertText, 48 + "insertCompositionText" => InputType::InsertCompositionText, 49 + "insertLineBreak" => InputType::InsertLineBreak, 50 + "insertParagraph" => InputType::InsertParagraph, 51 + "insertFromPaste" => InputType::InsertFromPaste, 52 + "insertFromDrop" => InputType::InsertFromDrop, 53 + "insertReplacementText" => InputType::InsertReplacementText, 54 + "insertFromYank" => InputType::InsertFromYank, 55 + "insertHorizontalRule" => InputType::InsertHorizontalRule, 56 + "insertOrderedList" => InputType::InsertOrderedList, 57 + "insertUnorderedList" => InputType::InsertUnorderedList, 58 + "insertLink" => InputType::InsertLink, 59 + 60 + // Deletion 61 + "deleteContentBackward" => InputType::DeleteContentBackward, 62 + "deleteContentForward" => InputType::DeleteContentForward, 63 + "deleteWordBackward" => InputType::DeleteWordBackward, 64 + "deleteWordForward" => InputType::DeleteWordForward, 65 + "deleteSoftLineBackward" => InputType::DeleteSoftLineBackward, 66 + "deleteSoftLineForward" => InputType::DeleteSoftLineForward, 67 + "deleteHardLineBackward" => InputType::DeleteHardLineBackward, 68 + "deleteHardLineForward" => InputType::DeleteHardLineForward, 69 + "deleteByCut" => InputType::DeleteByCut, 70 + "deleteByDrag" => InputType::DeleteByDrag, 71 + "deleteContent" => InputType::DeleteContent, 72 + "deleteEntireSoftLine" => InputType::DeleteSoftLineBackward, 73 + "deleteEntireWordBackward" => InputType::DeleteEntireWordBackward, 74 + "deleteEntireWordForward" => InputType::DeleteEntireWordForward, 75 + 76 + // History 77 + "historyUndo" => InputType::HistoryUndo, 78 + "historyRedo" => InputType::HistoryRedo, 79 + 80 + // Formatting 81 + "formatBold" => InputType::FormatBold, 82 + "formatItalic" => InputType::FormatItalic, 83 + "formatUnderline" => InputType::FormatUnderline, 84 + "formatStrikethrough" => InputType::FormatStrikethrough, 85 + "formatSuperscript" => InputType::FormatSuperscript, 86 + "formatSubscript" => InputType::FormatSubscript, 87 + 88 + // Unknown 89 + other => InputType::Unknown(other.to_string()), 90 + } 91 + } 92 + 93 + // === BeforeInput event handling === 94 + 95 + /// Result of handling a beforeinput event. 96 + #[derive(Debug, Clone)] 97 + pub enum BeforeInputResult { 98 + /// Event was handled, prevent default browser behavior. 99 + Handled, 100 + /// Event should be handled by browser (e.g., during composition). 101 + PassThrough, 102 + /// Event was handled but requires async follow-up (e.g., paste). 103 + HandledAsync, 104 + /// Android backspace workaround: defer and check if browser handled it. 105 + DeferredCheck { 106 + /// The action to execute if browser didn't handle it. 107 + fallback_action: weaver_editor_core::EditorAction, 108 + }, 109 + } 110 + 111 + /// Context for beforeinput handling. 112 + pub struct BeforeInputContext<'a> { 113 + /// The input type. 114 + pub input_type: InputType, 115 + /// The data (text to insert, if any). 116 + pub data: Option<String>, 117 + /// Target range from getTargetRanges(), if available. 118 + pub target_range: Option<Range>, 119 + /// Whether the event is part of an IME composition. 120 + pub is_composing: bool, 121 + /// Whether we're on Android. 122 + pub is_android: bool, 123 + /// Whether we're on Chrome. 124 + pub is_chrome: bool, 125 + /// Offset mappings for the document. 126 + pub offset_map: &'a [OffsetMapping], 127 + } 128 + 129 + /// Extract target range from a beforeinput event. 130 + /// 131 + /// Uses getTargetRanges() to get the browser's intended range for this operation. 132 + pub fn get_target_range_from_event( 133 + event: &web_sys::InputEvent, 134 + editor_id: &str, 135 + offset_map: &[OffsetMapping], 136 + ) -> Option<Range> { 137 + use wasm_bindgen::JsCast; 138 + 139 + let ranges = event.get_target_ranges(); 140 + if ranges.length() == 0 { 141 + return None; 142 + } 143 + 144 + let static_range: StaticRange = ranges.get(0).unchecked_into(); 145 + 146 + let window = web_sys::window()?; 147 + let dom_document = window.document()?; 148 + let editor_element = dom_document.get_element_by_id(editor_id)?; 149 + 150 + let start_container = static_range.startContainer(); 151 + let start_offset = static_range.startOffset() as usize; 152 + let end_container = static_range.endContainer(); 153 + let end_offset = static_range.endOffset() as usize; 154 + 155 + let start = dom_position_to_text_offset( 156 + &dom_document, 157 + &editor_element, 158 + &start_container, 159 + start_offset, 160 + offset_map, 161 + None, 162 + )?; 163 + 164 + let end = dom_position_to_text_offset( 165 + &dom_document, 166 + &editor_element, 167 + &end_container, 168 + end_offset, 169 + offset_map, 170 + None, 171 + )?; 172 + 173 + Some(Range::new(start, end)) 174 + } 175 + 176 + /// Get data from a beforeinput event, handling different sources. 177 + pub fn get_data_from_event(event: &web_sys::InputEvent) -> Option<String> { 178 + // First try the data property. 179 + if let Some(data) = event.data() { 180 + if !data.is_empty() { 181 + return Some(data); 182 + } 183 + } 184 + 185 + // For paste/drop, try dataTransfer. 186 + if let Some(data_transfer) = event.data_transfer() { 187 + if let Ok(text) = data_transfer.get_data("text/plain") { 188 + if !text.is_empty() { 189 + return Some(text); 190 + } 191 + } 192 + } 193 + 194 + None 195 + } 196 + 197 + /// Get input type from a beforeinput event. 198 + pub fn get_input_type_from_event(event: &web_sys::InputEvent) -> InputType { 199 + parse_browser_input_type(&event.input_type()) 200 + } 201 + 202 + /// Check if the beforeinput event is during IME composition. 203 + pub fn is_composing(event: &web_sys::InputEvent) -> bool { 204 + event.is_composing() 205 + } 206 + 207 + // === Clipboard helpers === 208 + 209 + /// Write text to clipboard with both text/plain and custom MIME type. 210 + pub async fn write_clipboard_with_custom_type(text: &str) -> Result<(), JsValue> { 211 + use js_sys::{Array, Object, Reflect}; 212 + use web_sys::{Blob, BlobPropertyBag, ClipboardItem}; 213 + 214 + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 215 + let navigator = window.navigator(); 216 + let clipboard = navigator.clipboard(); 217 + 218 + let text_parts = Array::new(); 219 + text_parts.push(&JsValue::from_str(text)); 220 + 221 + let text_opts = BlobPropertyBag::new(); 222 + text_opts.set_type("text/plain"); 223 + let text_blob = Blob::new_with_str_sequence_and_options(&text_parts, &text_opts)?; 224 + 225 + let custom_opts = BlobPropertyBag::new(); 226 + custom_opts.set_type("text/markdown"); 227 + let custom_blob = Blob::new_with_str_sequence_and_options(&text_parts, &custom_opts)?; 228 + 229 + let item_data = Object::new(); 230 + Reflect::set(&item_data, &JsValue::from_str("text/plain"), &text_blob)?; 231 + Reflect::set( 232 + &item_data, 233 + &JsValue::from_str("text/markdown"), 234 + &custom_blob, 235 + )?; 236 + 237 + let clipboard_item = ClipboardItem::new_with_record_from_str_to_blob_promise(&item_data)?; 238 + let items = Array::new(); 239 + items.push(&clipboard_item); 240 + 241 + let promise = clipboard.write(&items); 242 + wasm_bindgen_futures::JsFuture::from(promise).await?; 243 + 244 + Ok(()) 245 + } 246 + 247 + /// Read text from clipboard. 248 + pub async fn read_clipboard_text() -> Result<Option<String>, JsValue> { 249 + let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?; 250 + let navigator = window.navigator(); 251 + let clipboard = navigator.clipboard(); 252 + 253 + let promise = clipboard.read_text(); 254 + let result: JsValue = wasm_bindgen_futures::JsFuture::from(promise).await?; 255 + 256 + Ok(result.as_string()) 257 + }
+11 -5
crates/weaver-editor-browser/src/lib.rs
··· 8 8 //! 9 9 //! - `cursor`: Selection API handling and cursor restoration 10 10 //! - `dom_sync`: DOM ↔ document state synchronization 11 - //! - `events`: beforeinput, keydown, paste event handlers 12 - //! - `contenteditable`: Editor element setup and management 11 + //! - `events`: beforeinput event handling and clipboard helpers 13 12 //! - `platform`: Browser/OS detection for platform-specific behavior 14 13 //! 15 14 //! # Re-exports ··· 29 28 // Browser cursor implementation 30 29 pub use cursor::BrowserCursor; 31 30 31 + // DOM sync types 32 + pub use dom_sync::{BrowserCursorSync, CursorSyncResult, ParagraphDomData}; 33 + 34 + // Event handling 35 + pub use events::{ 36 + BeforeInputContext, BeforeInputResult, StaticRange, get_data_from_event, 37 + get_input_type_from_event, get_target_range_from_event, is_composing, 38 + parse_browser_input_type, read_clipboard_text, write_clipboard_with_custom_type, 39 + }; 40 + 32 41 // Platform detection 33 42 pub use platform::{Platform, platform}; 34 - 35 - // TODO: contenteditable module 36 - // TODO: embed worker module
+183
crates/weaver-editor-core/src/actions.rs
··· 457 457 pub alt: bool, 458 458 pub shift: bool, 459 459 pub meta: bool, 460 + pub hyper: bool, 461 + pub super_: bool, 460 462 } 461 463 462 464 impl Modifiers { ··· 465 467 alt: false, 466 468 shift: false, 467 469 meta: false, 470 + hyper: false, 471 + super_: false, 468 472 }; 469 473 470 474 pub const CTRL: Self = Self { ··· 472 476 alt: false, 473 477 shift: false, 474 478 meta: false, 479 + hyper: false, 480 + super_: false, 475 481 }; 476 482 477 483 pub const ALT: Self = Self { ··· 479 485 alt: true, 480 486 shift: false, 481 487 meta: false, 488 + hyper: false, 489 + super_: false, 482 490 }; 483 491 484 492 pub const SHIFT: Self = Self { ··· 486 494 alt: false, 487 495 shift: true, 488 496 meta: false, 497 + hyper: false, 498 + super_: false, 489 499 }; 490 500 491 501 pub const META: Self = Self { ··· 493 503 alt: false, 494 504 shift: false, 495 505 meta: true, 506 + hyper: false, 507 + super_: false, 508 + }; 509 + 510 + pub const HYPER: Self = Self { 511 + ctrl: false, 512 + alt: false, 513 + shift: false, 514 + meta: false, 515 + hyper: true, 516 + super_: false, 517 + }; 518 + 519 + pub const SUPER: Self = Self { 520 + ctrl: false, 521 + alt: false, 522 + shift: false, 523 + meta: false, 524 + hyper: false, 525 + super_: true, 496 526 }; 497 527 498 528 pub const CTRL_SHIFT: Self = Self { ··· 500 530 alt: false, 501 531 shift: true, 502 532 meta: false, 533 + hyper: false, 534 + super_: false, 503 535 }; 504 536 505 537 pub const META_SHIFT: Self = Self { ··· 507 539 alt: false, 508 540 shift: true, 509 541 meta: true, 542 + hyper: false, 543 + super_: false, 510 544 }; 511 545 512 546 /// Get the primary modifier for the platform (Cmd on Mac, Ctrl elsewhere). ··· 593 627 /// Event should be passed through (navigation, etc.). 594 628 PassThrough, 595 629 } 630 + 631 + // === Keybinding configuration === 632 + 633 + use std::collections::HashMap; 634 + 635 + /// Keybinding configuration for the editor. 636 + /// 637 + /// Maps key combinations to editor actions. Platform-specific defaults 638 + /// can be created via `default_for_platform`. 639 + #[derive(Debug, Clone)] 640 + pub struct KeybindingConfig { 641 + bindings: HashMap<KeyCombo, EditorAction>, 642 + } 643 + 644 + impl Default for KeybindingConfig { 645 + fn default() -> Self { 646 + Self::default_for_platform(false) 647 + } 648 + } 649 + 650 + impl KeybindingConfig { 651 + /// Create an empty keybinding configuration. 652 + pub fn new() -> Self { 653 + Self { 654 + bindings: HashMap::new(), 655 + } 656 + } 657 + 658 + /// Create default keybindings for the given platform. 659 + /// 660 + /// `is_mac` determines whether to use Cmd (true) or Ctrl (false) for shortcuts. 661 + pub fn default_for_platform(is_mac: bool) -> Self { 662 + let mut bindings = HashMap::new(); 663 + 664 + // === Formatting === 665 + bindings.insert( 666 + KeyCombo::primary(Key::character("b"), is_mac), 667 + EditorAction::ToggleBold, 668 + ); 669 + bindings.insert( 670 + KeyCombo::primary(Key::character("i"), is_mac), 671 + EditorAction::ToggleItalic, 672 + ); 673 + bindings.insert( 674 + KeyCombo::primary(Key::character("e"), is_mac), 675 + EditorAction::CopyAsHtml, 676 + ); 677 + 678 + // === History === 679 + bindings.insert( 680 + KeyCombo::primary(Key::character("z"), is_mac), 681 + EditorAction::Undo, 682 + ); 683 + 684 + // Redo: Cmd+Shift+Z on Mac, Ctrl+Y or Ctrl+Shift+Z elsewhere 685 + if is_mac { 686 + bindings.insert( 687 + KeyCombo::primary_shift(Key::character("Z"), is_mac), 688 + EditorAction::Redo, 689 + ); 690 + } else { 691 + bindings.insert(KeyCombo::ctrl(Key::character("y")), EditorAction::Redo); 692 + bindings.insert( 693 + KeyCombo::with_modifiers(Key::character("Z"), Modifiers::CTRL_SHIFT), 694 + EditorAction::Redo, 695 + ); 696 + } 697 + 698 + // === Selection === 699 + bindings.insert( 700 + KeyCombo::primary(Key::character("a"), is_mac), 701 + EditorAction::SelectAll, 702 + ); 703 + 704 + // === Line deletion === 705 + if is_mac { 706 + bindings.insert( 707 + KeyCombo::meta(Key::Backspace), 708 + EditorAction::DeleteToLineStart { 709 + range: Range::caret(0), 710 + }, 711 + ); 712 + bindings.insert( 713 + KeyCombo::meta(Key::Delete), 714 + EditorAction::DeleteToLineEnd { 715 + range: Range::caret(0), 716 + }, 717 + ); 718 + } 719 + 720 + // === Enter behaviour === 721 + // Enter = soft break (single newline) 722 + bindings.insert( 723 + KeyCombo::new(Key::Enter), 724 + EditorAction::InsertLineBreak { 725 + range: Range::caret(0), 726 + }, 727 + ); 728 + // Shift+Enter = paragraph break (double newline) 729 + bindings.insert( 730 + KeyCombo::shift(Key::Enter), 731 + EditorAction::InsertParagraph { 732 + range: Range::caret(0), 733 + }, 734 + ); 735 + 736 + // === Dedicated keys === 737 + bindings.insert(KeyCombo::new(Key::Undo), EditorAction::Undo); 738 + bindings.insert(KeyCombo::new(Key::Redo), EditorAction::Redo); 739 + bindings.insert(KeyCombo::new(Key::Copy), EditorAction::Copy); 740 + bindings.insert(KeyCombo::new(Key::Cut), EditorAction::Cut); 741 + bindings.insert( 742 + KeyCombo::new(Key::Paste), 743 + EditorAction::Paste { 744 + range: Range::caret(0), 745 + }, 746 + ); 747 + bindings.insert(KeyCombo::new(Key::Select), EditorAction::SelectAll); 748 + 749 + Self { bindings } 750 + } 751 + 752 + /// Look up an action for the given key combo. 753 + /// 754 + /// The range in the returned action is updated to the provided range. 755 + pub fn lookup(&self, combo: &KeyCombo, range: Range) -> Option<EditorAction> { 756 + self.bindings.get(combo).cloned().map(|a| a.with_range(range)) 757 + } 758 + 759 + /// Add or replace a keybinding. 760 + pub fn bind(&mut self, combo: KeyCombo, action: EditorAction) { 761 + self.bindings.insert(combo, action); 762 + } 763 + 764 + /// Remove a keybinding. 765 + pub fn unbind(&mut self, combo: &KeyCombo) { 766 + self.bindings.remove(combo); 767 + } 768 + 769 + /// Check if a key combo has a binding. 770 + pub fn has_binding(&self, combo: &KeyCombo) -> bool { 771 + self.bindings.contains_key(combo) 772 + } 773 + 774 + /// Iterate over all bindings. 775 + pub fn iter(&self) -> impl Iterator<Item = (&KeyCombo, &EditorAction)> { 776 + self.bindings.iter() 777 + } 778 + }
+522
crates/weaver-editor-core/src/execute.rs
··· 1 + //! Action execution for editor documents. 2 + //! 3 + //! This module provides the `execute_action` function that applies `EditorAction` 4 + //! operations to any type implementing `EditorDocument`. The logic is generic 5 + //! and platform-agnostic. 6 + 7 + use crate::actions::{EditorAction, Range}; 8 + use crate::document::EditorDocument; 9 + use crate::text_helpers::{ 10 + ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward, 11 + find_word_boundary_forward, is_list_item_empty, 12 + }; 13 + use crate::types::Selection; 14 + 15 + /// Execute an editor action on a document. 16 + /// 17 + /// This is the central dispatch point for all editor operations. 18 + /// Returns true if the action was handled and the document was modified. 19 + pub fn execute_action<D: EditorDocument>(doc: &mut D, action: &EditorAction) -> bool { 20 + match action { 21 + EditorAction::Insert { text, range } => execute_insert(doc, text, *range), 22 + EditorAction::InsertLineBreak { range } => execute_insert_line_break(doc, *range), 23 + EditorAction::InsertParagraph { range } => execute_insert_paragraph(doc, *range), 24 + EditorAction::DeleteBackward { range } => execute_delete_backward(doc, *range), 25 + EditorAction::DeleteForward { range } => execute_delete_forward(doc, *range), 26 + EditorAction::DeleteWordBackward { range } => execute_delete_word_backward(doc, *range), 27 + EditorAction::DeleteWordForward { range } => execute_delete_word_forward(doc, *range), 28 + EditorAction::DeleteToLineStart { range } => execute_delete_to_line_start(doc, *range), 29 + EditorAction::DeleteToLineEnd { range } => execute_delete_to_line_end(doc, *range), 30 + EditorAction::DeleteSoftLineBackward { range } => { 31 + execute_action(doc, &EditorAction::DeleteToLineStart { range: *range }) 32 + } 33 + EditorAction::DeleteSoftLineForward { range } => { 34 + execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range }) 35 + } 36 + EditorAction::Undo => execute_undo(doc), 37 + EditorAction::Redo => execute_redo(doc), 38 + EditorAction::ToggleBold => execute_toggle_format(doc, "**"), 39 + EditorAction::ToggleItalic => execute_toggle_format(doc, "*"), 40 + EditorAction::ToggleCode => execute_toggle_format(doc, "`"), 41 + EditorAction::ToggleStrikethrough => execute_toggle_format(doc, "~~"), 42 + EditorAction::InsertLink => execute_insert_link(doc), 43 + EditorAction::Cut | EditorAction::Copy | EditorAction::CopyAsHtml => { 44 + // Clipboard operations are handled by platform layer. 45 + false 46 + } 47 + EditorAction::Paste { range: _ } => { 48 + // Paste is handled by platform layer with clipboard access. 49 + false 50 + } 51 + EditorAction::SelectAll => execute_select_all(doc), 52 + EditorAction::MoveCursor { offset } => execute_move_cursor(doc, *offset), 53 + EditorAction::ExtendSelection { offset } => execute_extend_selection(doc, *offset), 54 + } 55 + } 56 + 57 + fn execute_insert<D: EditorDocument>(doc: &mut D, text: &str, range: Range) -> bool { 58 + let range = range.normalize(); 59 + 60 + // Clean up any preceding zero-width chars. 61 + let mut delete_start = range.start; 62 + while delete_start > 0 { 63 + match doc.char_at(delete_start - 1) { 64 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 65 + _ => break, 66 + } 67 + } 68 + 69 + let zw_count = range.start - delete_start; 70 + 71 + if range.is_caret() { 72 + if zw_count > 0 { 73 + doc.replace(delete_start..range.start, text); 74 + } else if range.start == doc.len_chars() { 75 + doc.insert(range.start, text); 76 + } else { 77 + doc.insert(range.start, text); 78 + } 79 + } else { 80 + // Replace selection. 81 + if zw_count > 0 { 82 + // Delete zero-width chars before selection start too. 83 + doc.replace(delete_start..range.end, text); 84 + } else { 85 + doc.replace(range.start..range.end, text); 86 + } 87 + } 88 + 89 + doc.set_selection(None); 90 + true 91 + } 92 + 93 + fn execute_insert_line_break<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 94 + let range = range.normalize(); 95 + let offset = range.start; 96 + 97 + // Delete selection if any. 98 + if !range.is_caret() { 99 + doc.delete(offset..range.end); 100 + } 101 + 102 + // Check if we're right after a soft break (newline + zero-width char). 103 + let is_double_enter = if offset >= 2 { 104 + let prev_char = doc.char_at(offset - 1); 105 + let prev_prev_char = doc.char_at(offset - 2); 106 + prev_char == Some('\u{200C}') && prev_prev_char == Some('\n') 107 + } else { 108 + false 109 + }; 110 + 111 + if !is_double_enter { 112 + // Check for list context. 113 + if let Some(ctx) = detect_list_context(doc, offset) { 114 + if is_list_item_empty(doc, offset, &ctx) { 115 + // Empty item - exit list. 116 + let line_start = find_line_start(doc, offset); 117 + let line_end = find_line_end(doc, offset); 118 + let delete_end = (line_end + 1).min(doc.len_chars()); 119 + doc.replace(line_start..delete_end, "\n\n\u{200C}\n"); 120 + doc.set_cursor_offset(line_start + 2); 121 + } else { 122 + // Continue list. 123 + let continuation = list_continuation(&ctx); 124 + let len = continuation.chars().count(); 125 + doc.insert(offset, &continuation); 126 + doc.set_cursor_offset(offset + len); 127 + } 128 + } else { 129 + // Normal soft break: insert newline + zero-width char. 130 + doc.insert(offset, "\n\u{200C}"); 131 + doc.set_cursor_offset(offset + 2); 132 + } 133 + } else { 134 + // Replace zero-width char with newline. 135 + doc.replace(offset - 1..offset, "\n"); 136 + } 137 + 138 + doc.set_selection(None); 139 + true 140 + } 141 + 142 + fn execute_insert_paragraph<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 143 + let range = range.normalize(); 144 + let cursor_offset = range.start; 145 + 146 + // Delete selection if any. 147 + if !range.is_caret() { 148 + doc.delete(cursor_offset..range.end); 149 + } 150 + 151 + // Check for list context. 152 + if let Some(ctx) = detect_list_context(doc, cursor_offset) { 153 + if is_list_item_empty(doc, cursor_offset, &ctx) { 154 + // Empty item - exit list. 155 + let line_start = find_line_start(doc, cursor_offset); 156 + let line_end = find_line_end(doc, cursor_offset); 157 + let delete_end = (line_end + 1).min(doc.len_chars()); 158 + doc.replace(line_start..delete_end, "\n\n\u{200C}\n"); 159 + doc.set_cursor_offset(line_start + 2); 160 + } else { 161 + // Continue list. 162 + let continuation = list_continuation(&ctx); 163 + let len = continuation.chars().count(); 164 + doc.insert(cursor_offset, &continuation); 165 + doc.set_cursor_offset(cursor_offset + len); 166 + } 167 + } else { 168 + // Normal paragraph break. 169 + doc.insert(cursor_offset, "\n\n"); 170 + doc.set_cursor_offset(cursor_offset + 2); 171 + } 172 + 173 + doc.set_selection(None); 174 + true 175 + } 176 + 177 + fn execute_delete_backward<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 178 + let range = range.normalize(); 179 + 180 + if !range.is_caret() { 181 + // Delete selection. 182 + doc.delete(range.start..range.end); 183 + return true; 184 + } 185 + 186 + if range.start == 0 { 187 + return false; 188 + } 189 + 190 + let cursor_offset = range.start; 191 + let prev_char = doc.char_at(cursor_offset - 1); 192 + 193 + if prev_char == Some('\n') { 194 + // Deleting a newline - handle paragraph merging. 195 + let newline_pos = cursor_offset - 1; 196 + let mut delete_start = newline_pos; 197 + let mut delete_end = cursor_offset; 198 + 199 + // Check for empty paragraph (double newline). 200 + if newline_pos > 0 && doc.char_at(newline_pos - 1) == Some('\n') { 201 + delete_start = newline_pos - 1; 202 + } 203 + 204 + // Check for trailing zero-width char. 205 + if let Some(ch) = doc.char_at(delete_end) { 206 + if ch == '\u{200C}' || ch == '\u{200B}' { 207 + delete_end += 1; 208 + } 209 + } 210 + 211 + // Scan backwards through zero-width chars. 212 + while delete_start > 0 { 213 + match doc.char_at(delete_start - 1) { 214 + Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 215 + Some('\n') | _ => break, 216 + } 217 + } 218 + 219 + doc.delete(delete_start..delete_end); 220 + } else { 221 + // Normal single char delete. 222 + doc.delete(cursor_offset - 1..cursor_offset); 223 + } 224 + 225 + doc.set_selection(None); 226 + true 227 + } 228 + 229 + fn execute_delete_forward<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 230 + let range = range.normalize(); 231 + 232 + if !range.is_caret() { 233 + doc.delete(range.start..range.end); 234 + return true; 235 + } 236 + 237 + if range.start >= doc.len_chars() { 238 + return false; 239 + } 240 + 241 + doc.delete(range.start..range.start + 1); 242 + doc.set_selection(None); 243 + true 244 + } 245 + 246 + fn execute_delete_word_backward<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 247 + let range = range.normalize(); 248 + 249 + if !range.is_caret() { 250 + doc.delete(range.start..range.end); 251 + return true; 252 + } 253 + 254 + let cursor = range.start; 255 + let word_start = find_word_boundary_backward(doc, cursor); 256 + if word_start < cursor { 257 + doc.delete(word_start..cursor); 258 + } 259 + 260 + doc.set_selection(None); 261 + true 262 + } 263 + 264 + fn execute_delete_word_forward<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 265 + let range = range.normalize(); 266 + 267 + if !range.is_caret() { 268 + doc.delete(range.start..range.end); 269 + return true; 270 + } 271 + 272 + let cursor = range.start; 273 + let word_end = find_word_boundary_forward(doc, cursor); 274 + if word_end > cursor { 275 + doc.delete(cursor..word_end); 276 + } 277 + 278 + doc.set_selection(None); 279 + true 280 + } 281 + 282 + fn execute_delete_to_line_start<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 283 + let range = range.normalize(); 284 + let cursor = range.start; 285 + let line_start = find_line_start(doc, cursor); 286 + 287 + if line_start < cursor { 288 + doc.delete(line_start..cursor); 289 + } 290 + 291 + doc.set_selection(None); 292 + true 293 + } 294 + 295 + fn execute_delete_to_line_end<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 296 + let range = range.normalize(); 297 + let cursor = if range.is_caret() { 298 + range.start 299 + } else { 300 + range.end 301 + }; 302 + let line_end = find_line_end(doc, cursor); 303 + 304 + if cursor < line_end { 305 + doc.delete(cursor..line_end); 306 + } 307 + 308 + doc.set_selection(None); 309 + true 310 + } 311 + 312 + fn execute_undo<D: EditorDocument>(doc: &mut D) -> bool { 313 + if doc.undo() { 314 + let max = doc.len_chars(); 315 + let cursor = doc.cursor(); 316 + if cursor.offset > max { 317 + doc.set_cursor_offset(max); 318 + } 319 + doc.set_selection(None); 320 + true 321 + } else { 322 + false 323 + } 324 + } 325 + 326 + fn execute_redo<D: EditorDocument>(doc: &mut D) -> bool { 327 + if doc.redo() { 328 + let max = doc.len_chars(); 329 + let cursor = doc.cursor(); 330 + if cursor.offset > max { 331 + doc.set_cursor_offset(max); 332 + } 333 + doc.set_selection(None); 334 + true 335 + } else { 336 + false 337 + } 338 + } 339 + 340 + fn execute_toggle_format<D: EditorDocument>(doc: &mut D, marker: &str) -> bool { 341 + let cursor_offset = doc.cursor_offset(); 342 + let (start, end) = if let Some(sel) = doc.selection() { 343 + (sel.start(), sel.end()) 344 + } else { 345 + find_word_boundaries(doc, cursor_offset) 346 + }; 347 + 348 + // Insert end marker first so start position stays valid. 349 + doc.insert(end, marker); 350 + doc.insert(start, marker); 351 + doc.set_cursor_offset(end + marker.len() * 2); 352 + doc.set_selection(None); 353 + true 354 + } 355 + 356 + fn execute_insert_link<D: EditorDocument>(doc: &mut D) -> bool { 357 + let cursor_offset = doc.cursor_offset(); 358 + let (start, end) = if let Some(sel) = doc.selection() { 359 + (sel.start(), sel.end()) 360 + } else { 361 + find_word_boundaries(doc, cursor_offset) 362 + }; 363 + 364 + // Insert [selected text](url) 365 + doc.insert(end, "](url)"); 366 + doc.insert(start, "["); 367 + doc.set_cursor_offset(end + 8); 368 + doc.set_selection(None); 369 + true 370 + } 371 + 372 + fn execute_select_all<D: EditorDocument>(doc: &mut D) -> bool { 373 + let len = doc.len_chars(); 374 + doc.set_selection(Some(Selection::new(0, len))); 375 + doc.set_cursor_offset(len); 376 + true 377 + } 378 + 379 + fn execute_move_cursor<D: EditorDocument>(doc: &mut D, offset: usize) -> bool { 380 + let offset = offset.min(doc.len_chars()); 381 + doc.set_cursor_offset(offset); 382 + doc.set_selection(None); 383 + true 384 + } 385 + 386 + fn execute_extend_selection<D: EditorDocument>(doc: &mut D, offset: usize) -> bool { 387 + let offset = offset.min(doc.len_chars()); 388 + let anchor = doc 389 + .selection() 390 + .map(|s| s.anchor) 391 + .unwrap_or_else(|| doc.cursor_offset()); 392 + doc.set_selection(Some(Selection::new(anchor, offset))); 393 + doc.set_cursor_offset(offset); 394 + true 395 + } 396 + 397 + /// Find word boundaries around cursor position. 398 + fn find_word_boundaries<D: EditorDocument>(doc: &D, offset: usize) -> (usize, usize) { 399 + let len = doc.len_chars(); 400 + 401 + // Find start by scanning backwards. 402 + let mut start = 0; 403 + for i in (0..offset).rev() { 404 + match doc.char_at(i) { 405 + Some(c) if c.is_whitespace() => { 406 + start = i + 1; 407 + break; 408 + } 409 + Some(_) => continue, 410 + None => break, 411 + } 412 + } 413 + 414 + // Find end by scanning forwards. 415 + let mut end = len; 416 + for i in offset..len { 417 + match doc.char_at(i) { 418 + Some(c) if c.is_whitespace() => { 419 + end = i; 420 + break; 421 + } 422 + Some(_) => continue, 423 + None => break, 424 + } 425 + } 426 + 427 + (start, end) 428 + } 429 + 430 + /// Generate list continuation text. 431 + fn list_continuation(ctx: &ListContext) -> String { 432 + match ctx { 433 + ListContext::Unordered { indent, marker } => { 434 + format!("\n{}{} ", indent, marker) 435 + } 436 + ListContext::Ordered { indent, number } => { 437 + format!("\n{}{}. ", indent, number + 1) 438 + } 439 + } 440 + } 441 + 442 + #[cfg(test)] 443 + mod tests { 444 + use super::*; 445 + use crate::{EditorRope, PlainEditor, UndoableBuffer}; 446 + 447 + type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>; 448 + 449 + fn make_editor(content: &str) -> TestEditor { 450 + let rope = EditorRope::from_str(content); 451 + let buf = UndoableBuffer::new(rope, 100); 452 + PlainEditor::new(buf) 453 + } 454 + 455 + #[test] 456 + fn test_insert() { 457 + let mut editor = make_editor("hello"); 458 + let action = EditorAction::Insert { 459 + text: " world".to_string(), 460 + range: Range::caret(5), 461 + }; 462 + assert!(execute_action(&mut editor, &action)); 463 + assert_eq!(editor.content_string(), "hello world"); 464 + } 465 + 466 + #[test] 467 + fn test_delete_backward() { 468 + let mut editor = make_editor("hello"); 469 + editor.set_cursor_offset(5); 470 + let action = EditorAction::DeleteBackward { 471 + range: Range::caret(5), 472 + }; 473 + assert!(execute_action(&mut editor, &action)); 474 + assert_eq!(editor.content_string(), "hell"); 475 + } 476 + 477 + #[test] 478 + fn test_delete_selection() { 479 + let mut editor = make_editor("hello world"); 480 + editor.set_selection(Some(Selection::new(5, 11))); 481 + let action = EditorAction::DeleteBackward { 482 + range: Range::new(5, 11), 483 + }; 484 + assert!(execute_action(&mut editor, &action)); 485 + assert_eq!(editor.content_string(), "hello"); 486 + } 487 + 488 + #[test] 489 + fn test_undo_redo() { 490 + let mut editor = make_editor("hello"); 491 + 492 + let action = EditorAction::Insert { 493 + text: " world".to_string(), 494 + range: Range::caret(5), 495 + }; 496 + execute_action(&mut editor, &action); 497 + assert_eq!(editor.content_string(), "hello world"); 498 + 499 + assert!(execute_action(&mut editor, &EditorAction::Undo)); 500 + assert_eq!(editor.content_string(), "hello"); 501 + 502 + assert!(execute_action(&mut editor, &EditorAction::Redo)); 503 + assert_eq!(editor.content_string(), "hello world"); 504 + } 505 + 506 + #[test] 507 + fn test_select_all() { 508 + let mut editor = make_editor("hello world"); 509 + assert!(execute_action(&mut editor, &EditorAction::SelectAll)); 510 + let sel = editor.selection().unwrap(); 511 + assert_eq!(sel.start(), 0); 512 + assert_eq!(sel.end(), 11); 513 + } 514 + 515 + #[test] 516 + fn test_toggle_bold() { 517 + let mut editor = make_editor("hello"); 518 + editor.set_selection(Some(Selection::new(0, 5))); 519 + assert!(execute_action(&mut editor, &EditorAction::ToggleBold)); 520 + assert_eq!(editor.content_string(), "**hello**"); 521 + } 522 + }
+13
crates/weaver-editor-core/src/lib.rs
··· 6 6 //! - `UndoableBuffer<T>` - TextBuffer wrapper with undo/redo 7 7 //! - `EditorDocument` trait - interface for editor implementations 8 8 //! - `PlainEditor<T>` - simple field-based EditorDocument impl 9 + //! - `EditorAction`, `InputType`, `Key` - platform-agnostic input/action types 9 10 //! - Rendering types and offset mapping utilities 10 11 12 + pub mod actions; 11 13 pub mod document; 14 + pub mod execute; 12 15 pub mod offset_map; 13 16 pub mod paragraph; 14 17 pub mod platform; 15 18 pub mod render; 16 19 pub mod syntax; 17 20 pub mod text; 21 + pub mod text_helpers; 18 22 pub mod types; 19 23 pub mod undo; 20 24 pub mod visibility; ··· 38 42 pub use visibility::VisibilityState; 39 43 pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult}; 40 44 pub use platform::{CursorPlatform, CursorSync, PlatformError}; 45 + pub use actions::{ 46 + EditorAction, InputType, Key, KeyCombo, KeybindingConfig, KeydownResult, Modifiers, Range, 47 + }; 48 + pub use execute::execute_action; 49 + pub use text_helpers::{ 50 + ListContext, count_leading_zero_width, detect_list_context, find_line_end, find_line_start, 51 + find_word_boundary_backward, find_word_boundary_forward, is_list_item_empty, 52 + is_zero_width_char, 53 + };
+286
crates/weaver-editor-core/src/text_helpers.rs
··· 1 + //! Text navigation and analysis helpers. 2 + //! 3 + //! These functions work with the `EditorDocument` trait to provide 4 + //! common text operations like finding line boundaries and word boundaries. 5 + 6 + use crate::document::EditorDocument; 7 + 8 + /// Find start of line containing offset. 9 + pub fn find_line_start<D: EditorDocument>(doc: &D, offset: usize) -> usize { 10 + if offset == 0 { 11 + return 0; 12 + } 13 + 14 + let mut pos = offset; 15 + while pos > 0 { 16 + if let Some('\n') = doc.char_at(pos - 1) { 17 + return pos; 18 + } 19 + pos -= 1; 20 + } 21 + 0 22 + } 23 + 24 + /// Find end of line containing offset (position of newline or end of doc). 25 + pub fn find_line_end<D: EditorDocument>(doc: &D, offset: usize) -> usize { 26 + let len = doc.len_chars(); 27 + if offset >= len { 28 + return len; 29 + } 30 + 31 + let mut pos = offset; 32 + while pos < len { 33 + if let Some('\n') = doc.char_at(pos) { 34 + return pos; 35 + } 36 + pos += 1; 37 + } 38 + len 39 + } 40 + 41 + /// Find word boundary backward from cursor. 42 + pub fn find_word_boundary_backward<D: EditorDocument>(doc: &D, cursor: usize) -> usize { 43 + if cursor == 0 { 44 + return 0; 45 + } 46 + 47 + let mut pos = cursor; 48 + 49 + // Skip any whitespace/punctuation immediately before cursor. 50 + while pos > 0 { 51 + match doc.char_at(pos - 1) { 52 + Some(c) if c.is_alphanumeric() || c == '_' => break, 53 + Some(_) => pos -= 1, 54 + None => break, 55 + } 56 + } 57 + 58 + // Skip the word characters. 59 + while pos > 0 { 60 + match doc.char_at(pos - 1) { 61 + Some(c) if c.is_alphanumeric() || c == '_' => pos -= 1, 62 + _ => break, 63 + } 64 + } 65 + 66 + pos 67 + } 68 + 69 + /// Find word boundary forward from cursor. 70 + pub fn find_word_boundary_forward<D: EditorDocument>(doc: &D, cursor: usize) -> usize { 71 + let len = doc.len_chars(); 72 + if cursor >= len { 73 + return len; 74 + } 75 + 76 + let mut pos = cursor; 77 + 78 + // Skip word characters first. 79 + while pos < len { 80 + match doc.char_at(pos) { 81 + Some(c) if c.is_alphanumeric() || c == '_' => pos += 1, 82 + _ => break, 83 + } 84 + } 85 + 86 + // Then skip whitespace/punctuation. 87 + while pos < len { 88 + match doc.char_at(pos) { 89 + Some(c) if c.is_alphanumeric() || c == '_' => break, 90 + Some(_) => pos += 1, 91 + None => break, 92 + } 93 + } 94 + 95 + pos 96 + } 97 + 98 + /// Describes what kind of list item the cursor is in, if any. 99 + #[derive(Debug, Clone)] 100 + pub enum ListContext { 101 + /// Unordered list with the given marker char ('-' or '*') and indentation. 102 + Unordered { indent: String, marker: char }, 103 + /// Ordered list with the current number and indentation. 104 + Ordered { indent: String, number: usize }, 105 + } 106 + 107 + /// Detect if cursor is in a list item and return context for continuation. 108 + pub fn detect_list_context<D: EditorDocument>(doc: &D, cursor_offset: usize) -> Option<ListContext> { 109 + let line_start = find_line_start(doc, cursor_offset); 110 + let line_end = find_line_end(doc, cursor_offset); 111 + 112 + if line_start >= line_end { 113 + return None; 114 + } 115 + 116 + let line = doc.slice(line_start..line_end)?; 117 + 118 + // Parse indentation. 119 + let indent: String = line 120 + .chars() 121 + .take_while(|c| *c == ' ' || *c == '\t') 122 + .collect(); 123 + let trimmed = &line[indent.len()..]; 124 + 125 + // Check for unordered list marker: "- " or "* ". 126 + if trimmed.starts_with("- ") { 127 + return Some(ListContext::Unordered { 128 + indent, 129 + marker: '-', 130 + }); 131 + } 132 + if trimmed.starts_with("* ") { 133 + return Some(ListContext::Unordered { 134 + indent, 135 + marker: '*', 136 + }); 137 + } 138 + 139 + // Check for ordered list marker: "1. ", "2. ", etc. 140 + if let Some(dot_pos) = trimmed.find(". ") { 141 + let num_part = &trimmed[..dot_pos]; 142 + if !num_part.is_empty() && num_part.chars().all(|c| c.is_ascii_digit()) { 143 + if let Ok(number) = num_part.parse::<usize>() { 144 + return Some(ListContext::Ordered { indent, number }); 145 + } 146 + } 147 + } 148 + 149 + None 150 + } 151 + 152 + /// Check if the current list item is empty (just the marker, no content). 153 + pub fn is_list_item_empty<D: EditorDocument>( 154 + doc: &D, 155 + cursor_offset: usize, 156 + ctx: &ListContext, 157 + ) -> bool { 158 + let line_start = find_line_start(doc, cursor_offset); 159 + let line_end = find_line_end(doc, cursor_offset); 160 + 161 + let line = match doc.slice(line_start..line_end) { 162 + Some(s) => s, 163 + None => return false, 164 + }; 165 + 166 + // Calculate expected marker length. 167 + let marker_len = match ctx { 168 + ListContext::Unordered { indent, .. } => indent.len() + 2, // "- " 169 + ListContext::Ordered { indent, number } => { 170 + indent.len() + number.to_string().len() + 2 // "1. " 171 + } 172 + }; 173 + 174 + line.len() <= marker_len 175 + } 176 + 177 + /// Count leading zero-width characters before offset. 178 + pub fn count_leading_zero_width<D: EditorDocument>(doc: &D, offset: usize) -> usize { 179 + let mut count = 0; 180 + let mut pos = offset; 181 + 182 + while pos > 0 { 183 + match doc.char_at(pos - 1) { 184 + Some('\u{200C}') | Some('\u{200B}') => { 185 + count += 1; 186 + pos -= 1; 187 + } 188 + _ => break, 189 + } 190 + } 191 + 192 + count 193 + } 194 + 195 + /// Check if character at offset is a zero-width character. 196 + pub fn is_zero_width_char<D: EditorDocument>(doc: &D, offset: usize) -> bool { 197 + matches!(doc.char_at(offset), Some('\u{200C}') | Some('\u{200B}')) 198 + } 199 + 200 + #[cfg(test)] 201 + mod tests { 202 + use super::*; 203 + use crate::{EditorRope, PlainEditor, UndoableBuffer}; 204 + 205 + type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>; 206 + 207 + fn make_editor(content: &str) -> TestEditor { 208 + let rope = EditorRope::from_str(content); 209 + let buf = UndoableBuffer::new(rope, 100); 210 + PlainEditor::new(buf) 211 + } 212 + 213 + #[test] 214 + fn test_find_line_start() { 215 + let editor = make_editor("hello\nworld\ntest"); 216 + 217 + assert_eq!(find_line_start(&editor, 0), 0); 218 + assert_eq!(find_line_start(&editor, 3), 0); 219 + assert_eq!(find_line_start(&editor, 5), 0); // at newline 220 + assert_eq!(find_line_start(&editor, 6), 6); // start of "world" 221 + assert_eq!(find_line_start(&editor, 8), 6); 222 + assert_eq!(find_line_start(&editor, 12), 12); // start of "test" 223 + } 224 + 225 + #[test] 226 + fn test_find_line_end() { 227 + let editor = make_editor("hello\nworld\ntest"); 228 + 229 + assert_eq!(find_line_end(&editor, 0), 5); 230 + assert_eq!(find_line_end(&editor, 3), 5); 231 + assert_eq!(find_line_end(&editor, 6), 11); 232 + assert_eq!(find_line_end(&editor, 12), 16); 233 + } 234 + 235 + #[test] 236 + fn test_find_word_boundary_backward() { 237 + let editor = make_editor("hello world test"); 238 + 239 + assert_eq!(find_word_boundary_backward(&editor, 16), 12); // from end 240 + assert_eq!(find_word_boundary_backward(&editor, 12), 6); // from "test" 241 + assert_eq!(find_word_boundary_backward(&editor, 11), 6); // from space before "test" 242 + assert_eq!(find_word_boundary_backward(&editor, 5), 0); // from end of "hello" 243 + } 244 + 245 + #[test] 246 + fn test_find_word_boundary_forward() { 247 + let editor = make_editor("hello world test"); 248 + 249 + assert_eq!(find_word_boundary_forward(&editor, 0), 6); // from start 250 + assert_eq!(find_word_boundary_forward(&editor, 6), 12); // from space 251 + assert_eq!(find_word_boundary_forward(&editor, 12), 16); // from "test" 252 + } 253 + 254 + #[test] 255 + fn test_detect_list_context_unordered() { 256 + let editor = make_editor("- item one\n- item two"); 257 + 258 + let ctx = detect_list_context(&editor, 5); 259 + assert!(matches!(ctx, Some(ListContext::Unordered { marker: '-', .. }))); 260 + 261 + let ctx = detect_list_context(&editor, 15); 262 + assert!(matches!(ctx, Some(ListContext::Unordered { marker: '-', .. }))); 263 + } 264 + 265 + #[test] 266 + fn test_detect_list_context_ordered() { 267 + let editor = make_editor("1. first\n2. second"); 268 + 269 + let ctx = detect_list_context(&editor, 5); 270 + assert!(matches!(ctx, Some(ListContext::Ordered { number: 1, .. }))); 271 + 272 + let ctx = detect_list_context(&editor, 12); 273 + assert!(matches!(ctx, Some(ListContext::Ordered { number: 2, .. }))); 274 + } 275 + 276 + #[test] 277 + fn test_is_list_item_empty() { 278 + let editor = make_editor("- \n- item"); 279 + 280 + let ctx = detect_list_context(&editor, 1).unwrap(); 281 + assert!(is_list_item_empty(&editor, 1, &ctx)); 282 + 283 + let ctx = detect_list_context(&editor, 5).unwrap(); 284 + assert!(!is_list_item_empty(&editor, 5, &ctx)); 285 + } 286 + }
+132
docs/graph-data.json
··· 1286 1286 "created_at": "2026-01-06T11:35:39.555773236-05:00", 1287 1287 "updated_at": "2026-01-06T11:35:39.555773236-05:00", 1288 1288 "metadata_json": "{\"confidence\":95}" 1289 + }, 1290 + { 1291 + "id": 119, 1292 + "change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e", 1293 + "node_type": "action", 1294 + "title": "core: actions.rs with Range, EditorAction, InputType, Key, Modifiers, KeyCombo", 1295 + "description": null, 1296 + "status": "pending", 1297 + "created_at": "2026-01-06T11:48:31.913440141-05:00", 1298 + "updated_at": "2026-01-06T11:48:31.913440141-05:00", 1299 + "metadata_json": "{\"confidence\":90}" 1300 + }, 1301 + { 1302 + "id": 120, 1303 + "change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7", 1304 + "node_type": "action", 1305 + "title": "core: text_helpers.rs with find_line_start/end, word boundaries, list detection", 1306 + "description": null, 1307 + "status": "pending", 1308 + "created_at": "2026-01-06T11:48:48.907582364-05:00", 1309 + "updated_at": "2026-01-06T11:48:48.907582364-05:00", 1310 + "metadata_json": "{\"confidence\":90}" 1311 + }, 1312 + { 1313 + "id": 121, 1314 + "change_id": "1206764e-ee77-4dd4-b924-019177b1a495", 1315 + "node_type": "outcome", 1316 + "title": "weaver-editor-browser and weaver-editor-core compile with extracted actions, text helpers, and events", 1317 + "description": null, 1318 + "status": "pending", 1319 + "created_at": "2026-01-06T11:54:09.803705832-05:00", 1320 + "updated_at": "2026-01-06T11:54:09.803705832-05:00", 1321 + "metadata_json": "{\"confidence\":95}" 1322 + }, 1323 + { 1324 + "id": 122, 1325 + "change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", 1326 + "node_type": "goal", 1327 + "title": "Extract execute_action and related code to core/browser crates", 1328 + "description": null, 1329 + "status": "pending", 1330 + "created_at": "2026-01-06T11:57:25.012098492-05:00", 1331 + "updated_at": "2026-01-06T11:57:25.012098492-05:00", 1332 + "metadata_json": "{\"confidence\":85,\"prompt\":\"User asked: extract execute_action in generic fashion and scan editor components for other extractable code (non-crdt, non-dioxus)\"}" 1333 + }, 1334 + { 1335 + "id": 123, 1336 + "change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a", 1337 + "node_type": "action", 1338 + "title": "Created execute.rs in core with generic execute_action", 1339 + "description": null, 1340 + "status": "pending", 1341 + "created_at": "2026-01-06T12:01:21.232311329-05:00", 1342 + "updated_at": "2026-01-06T12:01:21.232311329-05:00", 1343 + "metadata_json": "{\"confidence\":95}" 1344 + }, 1345 + { 1346 + "id": 124, 1347 + "change_id": "51a84f63-8720-4a90-97af-50c9212d14d6", 1348 + "node_type": "outcome", 1349 + "title": "Core execute.rs complete with 75 tests passing", 1350 + "description": null, 1351 + "status": "pending", 1352 + "created_at": "2026-01-06T12:01:52.561290209-05:00", 1353 + "updated_at": "2026-01-06T12:01:52.561290209-05:00", 1354 + "metadata_json": "{\"confidence\":95}" 1289 1355 } 1290 1356 ], 1291 1357 "edges": [ ··· 2718 2784 "weight": 1.0, 2719 2785 "rationale": "API refinement", 2720 2786 "created_at": "2026-01-06T11:35:39.734624305-05:00" 2787 + }, 2788 + { 2789 + "id": 132, 2790 + "from_node_id": 81, 2791 + "to_node_id": 119, 2792 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2793 + "to_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e", 2794 + "edge_type": "leads_to", 2795 + "weight": 1.0, 2796 + "rationale": "core actions support browser crate", 2797 + "created_at": "2026-01-06T11:48:48.924160167-05:00" 2798 + }, 2799 + { 2800 + "id": 133, 2801 + "from_node_id": 116, 2802 + "to_node_id": 119, 2803 + "from_change_id": "cd5cc522-d717-43a7-b116-94f86845db10", 2804 + "to_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e", 2805 + "edge_type": "leads_to", 2806 + "weight": 1.0, 2807 + "rationale": "pattern applied: generic logic in core", 2808 + "created_at": "2026-01-06T11:48:48.940647360-05:00" 2809 + }, 2810 + { 2811 + "id": 134, 2812 + "from_node_id": 119, 2813 + "to_node_id": 120, 2814 + "from_change_id": "98dde6b1-ff25-47b3-bc02-7479a4341b1e", 2815 + "to_change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7", 2816 + "edge_type": "leads_to", 2817 + "weight": 1.0, 2818 + "rationale": "text helpers support action execution", 2819 + "created_at": "2026-01-06T11:54:09.820070547-05:00" 2820 + }, 2821 + { 2822 + "id": 135, 2823 + "from_node_id": 81, 2824 + "to_node_id": 120, 2825 + "from_change_id": "5f00148d-b487-40fb-b4b4-66b8d2489e91", 2826 + "to_change_id": "b192e6ab-9500-4e4b-b7c3-8b5d7a8453b7", 2827 + "edge_type": "leads_to", 2828 + "weight": 1.0, 2829 + "rationale": "browser crate extraction progress", 2830 + "created_at": "2026-01-06T11:54:09.837098743-05:00" 2831 + }, 2832 + { 2833 + "id": 136, 2834 + "from_node_id": 122, 2835 + "to_node_id": 123, 2836 + "from_change_id": "7179434c-6064-4ae7-9eac-1f89465e2479", 2837 + "to_change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a", 2838 + "edge_type": "leads_to", 2839 + "weight": 1.0, 2840 + "rationale": "Part of extracting action execution", 2841 + "created_at": "2026-01-06T12:01:21.248171774-05:00" 2842 + }, 2843 + { 2844 + "id": 137, 2845 + "from_node_id": 123, 2846 + "to_node_id": 124, 2847 + "from_change_id": "47ebda62-aefb-4f8b-9c22-860b0b89d32a", 2848 + "to_change_id": "51a84f63-8720-4a90-97af-50c9212d14d6", 2849 + "edge_type": "leads_to", 2850 + "weight": 1.0, 2851 + "rationale": "Execute action implementation outcome", 2852 + "created_at": "2026-01-06T12:01:52.577885198-05:00" 2721 2853 } 2722 2854 ] 2723 2855 }