at main 756 lines 25 kB view raw
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 7use crate::SnapDirection; 8use crate::actions::{EditorAction, FormatAction, Range}; 9use crate::document::EditorDocument; 10use crate::platform::{ClipboardPlatform, clipboard_copy, clipboard_cut, clipboard_paste}; 11use crate::text_helpers::{ 12 ListContext, detect_list_context, find_line_end, find_line_start, find_word_boundary_backward, 13 find_word_boundary_forward, is_list_item_empty, 14}; 15use crate::types::Selection; 16 17/// Determine the cursor snap direction hint for an action. 18/// 19/// Forward means cursor should snap toward new/remaining content (insertions). 20/// Backward means cursor should snap toward content before edit (deletions). 21pub fn snap_direction_for_action(action: &EditorAction) -> Option<SnapDirection> { 22 match action { 23 // Forward: cursor should snap toward new/remaining content. 24 EditorAction::InsertLineBreak { .. } 25 | EditorAction::InsertParagraph { .. } 26 | EditorAction::DeleteForward { .. } 27 | EditorAction::DeleteWordForward { .. } 28 | EditorAction::DeleteToLineEnd { .. } 29 | EditorAction::DeleteSoftLineForward { .. } => Some(SnapDirection::Forward), 30 31 // Backward: cursor should snap toward content before edit. 32 EditorAction::DeleteBackward { .. } 33 | EditorAction::DeleteWordBackward { .. } 34 | EditorAction::DeleteToLineStart { .. } 35 | EditorAction::DeleteSoftLineBackward { .. } => Some(SnapDirection::Backward), 36 37 _ => None, 38 } 39} 40 41/// Execute an editor action on a document. 42/// 43/// This is the central dispatch point for all editor operations. 44/// Sets the appropriate snap direction hint before executing. 45/// Returns true if the action was handled and the document was modified. 46/// 47/// Note: Clipboard operations (Cut, Copy, CopyAsHtml, Paste) return false here. 48/// Use [`execute_action_with_clipboard`] if you have a clipboard platform available. 49pub fn execute_action<D: EditorDocument>(doc: &mut D, action: &EditorAction) -> bool { 50 // Set pending snap direction before executing action. 51 if let Some(snap) = snap_direction_for_action(action) { 52 doc.set_pending_snap(Some(snap)); 53 } 54 55 match action { 56 EditorAction::Insert { text, range } => execute_insert(doc, text, *range), 57 EditorAction::InsertLineBreak { range } => execute_insert_line_break(doc, *range), 58 EditorAction::InsertParagraph { range } => execute_insert_paragraph(doc, *range), 59 EditorAction::DeleteBackward { range } => execute_delete_backward(doc, *range), 60 EditorAction::DeleteForward { range } => execute_delete_forward(doc, *range), 61 EditorAction::DeleteWordBackward { range } => execute_delete_word_backward(doc, *range), 62 EditorAction::DeleteWordForward { range } => execute_delete_word_forward(doc, *range), 63 EditorAction::DeleteToLineStart { range } => execute_delete_to_line_start(doc, *range), 64 EditorAction::DeleteToLineEnd { range } => execute_delete_to_line_end(doc, *range), 65 EditorAction::DeleteSoftLineBackward { range } => { 66 execute_action(doc, &EditorAction::DeleteToLineStart { range: *range }) 67 } 68 EditorAction::DeleteSoftLineForward { range } => { 69 execute_action(doc, &EditorAction::DeleteToLineEnd { range: *range }) 70 } 71 EditorAction::Undo => execute_undo(doc), 72 EditorAction::Redo => execute_redo(doc), 73 EditorAction::ToggleBold => execute_toggle_format(doc, "**"), 74 EditorAction::ToggleItalic => execute_toggle_format(doc, "*"), 75 EditorAction::ToggleCode => execute_toggle_format(doc, "`"), 76 EditorAction::ToggleStrikethrough => execute_toggle_format(doc, "~~"), 77 EditorAction::InsertLink => execute_insert_link(doc), 78 EditorAction::Cut | EditorAction::Copy | EditorAction::CopyAsHtml => { 79 // Clipboard operations need platform - use execute_action_with_clipboard. 80 false 81 } 82 EditorAction::Paste { range: _ } => { 83 // Paste needs platform - use execute_action_with_clipboard. 84 false 85 } 86 EditorAction::SelectAll => execute_select_all(doc), 87 EditorAction::MoveCursor { offset } => execute_move_cursor(doc, *offset), 88 EditorAction::ExtendSelection { offset } => execute_extend_selection(doc, *offset), 89 } 90} 91 92/// Execute an editor action with clipboard support. 93/// 94/// Like [`execute_action`], but also handles clipboard operations (Cut, Copy, Paste, CopyAsHtml) 95/// using the provided platform implementation. 96pub fn execute_action_with_clipboard<D, P>(doc: &mut D, action: &EditorAction, clipboard: &P) -> bool 97where 98 D: EditorDocument, 99 P: ClipboardPlatform, 100{ 101 match action { 102 EditorAction::Copy => clipboard_copy(doc, clipboard), 103 EditorAction::Cut => clipboard_cut(doc, clipboard), 104 EditorAction::Paste { range: _ } => clipboard_paste(doc, clipboard), 105 EditorAction::CopyAsHtml => crate::platform::clipboard_copy_as_html(doc, clipboard), 106 // Delegate everything else to the regular execute_action. 107 _ => execute_action(doc, action), 108 } 109} 110 111fn execute_insert<D: EditorDocument>(doc: &mut D, text: &str, range: Range) -> bool { 112 let range = range.normalize(); 113 114 // Clean up any preceding zero-width chars. 115 let mut delete_start = range.start; 116 while delete_start > 0 { 117 match doc.char_at(delete_start - 1) { 118 Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 119 _ => break, 120 } 121 } 122 123 let zw_count = range.start - delete_start; 124 125 if range.is_caret() { 126 if zw_count > 0 { 127 doc.replace(delete_start..range.start, text); 128 } else if range.start == doc.len_chars() { 129 doc.insert(range.start, text); 130 } else { 131 doc.insert(range.start, text); 132 } 133 } else { 134 // Replace selection. 135 if zw_count > 0 { 136 // Delete zero-width chars before selection start too. 137 doc.replace(delete_start..range.end, text); 138 } else { 139 doc.replace(range.start..range.end, text); 140 } 141 } 142 143 doc.set_selection(None); 144 true 145} 146 147fn execute_insert_line_break<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 148 let range = range.normalize(); 149 let offset = range.start; 150 151 // Delete selection if any. 152 if !range.is_caret() { 153 doc.delete(offset..range.end); 154 } 155 156 // Check if we're right after a soft break (newline + zero-width char). 157 let is_double_enter = if offset >= 2 { 158 let prev_char = doc.char_at(offset - 1); 159 let prev_prev_char = doc.char_at(offset - 2); 160 prev_char == Some('\u{200C}') && prev_prev_char == Some('\n') 161 } else { 162 false 163 }; 164 165 if !is_double_enter { 166 // Check for list context. 167 if let Some(ctx) = detect_list_context(doc, offset) { 168 if is_list_item_empty(doc, offset, &ctx) { 169 // Empty item - exit list. 170 let line_start = find_line_start(doc, offset); 171 let line_end = find_line_end(doc, offset); 172 let delete_end = (line_end + 1).min(doc.len_chars()); 173 doc.replace(line_start..delete_end, "\n\n\u{200C}\n"); 174 doc.set_cursor_offset(line_start + 2); 175 } else { 176 // Continue list. 177 let continuation = list_continuation(&ctx); 178 let len = continuation.chars().count(); 179 doc.insert(offset, &continuation); 180 doc.set_cursor_offset(offset + len); 181 } 182 } else { 183 // Normal soft break: insert newline + zero-width char. 184 doc.insert(offset, "\n\u{200C}"); 185 doc.set_cursor_offset(offset + 2); 186 } 187 } else { 188 // Replace zero-width char with newline. 189 doc.replace(offset - 1..offset, "\n"); 190 } 191 192 doc.set_selection(None); 193 true 194} 195 196fn execute_insert_paragraph<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 197 let range = range.normalize(); 198 let cursor_offset = range.start; 199 200 // Delete selection if any. 201 if !range.is_caret() { 202 doc.delete(cursor_offset..range.end); 203 } 204 205 // Check for list context. 206 if let Some(ctx) = detect_list_context(doc, cursor_offset) { 207 if is_list_item_empty(doc, cursor_offset, &ctx) { 208 // Empty item - exit list. 209 let line_start = find_line_start(doc, cursor_offset); 210 let line_end = find_line_end(doc, cursor_offset); 211 let delete_end = (line_end + 1).min(doc.len_chars()); 212 doc.replace(line_start..delete_end, "\n\n\u{200C}\n"); 213 doc.set_cursor_offset(line_start + 2); 214 } else { 215 // Continue list. 216 let continuation = list_continuation(&ctx); 217 let len = continuation.chars().count(); 218 doc.insert(cursor_offset, &continuation); 219 doc.set_cursor_offset(cursor_offset + len); 220 } 221 } else { 222 // Normal paragraph break. 223 doc.insert(cursor_offset, "\n\n"); 224 doc.set_cursor_offset(cursor_offset + 2); 225 } 226 227 doc.set_selection(None); 228 true 229} 230 231fn execute_delete_backward<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 232 let range = range.normalize(); 233 234 if !range.is_caret() { 235 // Delete selection. 236 doc.delete(range.start..range.end); 237 return true; 238 } 239 240 if range.start == 0 { 241 return false; 242 } 243 244 let cursor_offset = range.start; 245 let prev_char = doc.char_at(cursor_offset - 1); 246 247 if prev_char == Some('\n') { 248 // Deleting a newline - handle paragraph merging. 249 let newline_pos = cursor_offset - 1; 250 let mut delete_start = newline_pos; 251 let mut delete_end = cursor_offset; 252 253 // Check for empty paragraph (double newline). 254 if newline_pos > 0 && doc.char_at(newline_pos - 1) == Some('\n') { 255 delete_start = newline_pos - 1; 256 } 257 258 // Check for trailing zero-width char. 259 if let Some(ch) = doc.char_at(delete_end) { 260 if ch == '\u{200C}' || ch == '\u{200B}' { 261 delete_end += 1; 262 } 263 } 264 265 // Scan backwards through zero-width chars. 266 while delete_start > 0 { 267 match doc.char_at(delete_start - 1) { 268 Some('\u{200C}') | Some('\u{200B}') => delete_start -= 1, 269 Some('\n') | _ => break, 270 } 271 } 272 273 doc.delete(delete_start..delete_end); 274 } else { 275 // Normal single char delete. 276 doc.delete(cursor_offset - 1..cursor_offset); 277 } 278 279 doc.set_selection(None); 280 true 281} 282 283fn execute_delete_forward<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 284 let range = range.normalize(); 285 286 if !range.is_caret() { 287 doc.delete(range.start..range.end); 288 return true; 289 } 290 291 if range.start >= doc.len_chars() { 292 return false; 293 } 294 295 doc.delete(range.start..range.start + 1); 296 doc.set_selection(None); 297 true 298} 299 300fn execute_delete_word_backward<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 301 let range = range.normalize(); 302 303 if !range.is_caret() { 304 doc.delete(range.start..range.end); 305 return true; 306 } 307 308 let cursor = range.start; 309 let word_start = find_word_boundary_backward(doc, cursor); 310 if word_start < cursor { 311 doc.delete(word_start..cursor); 312 } 313 314 doc.set_selection(None); 315 true 316} 317 318fn execute_delete_word_forward<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 319 let range = range.normalize(); 320 321 if !range.is_caret() { 322 doc.delete(range.start..range.end); 323 return true; 324 } 325 326 let cursor = range.start; 327 let word_end = find_word_boundary_forward(doc, cursor); 328 if word_end > cursor { 329 doc.delete(cursor..word_end); 330 } 331 332 doc.set_selection(None); 333 true 334} 335 336fn execute_delete_to_line_start<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 337 let range = range.normalize(); 338 let cursor = range.start; 339 let line_start = find_line_start(doc, cursor); 340 341 if line_start < cursor { 342 doc.delete(line_start..cursor); 343 } 344 345 doc.set_selection(None); 346 true 347} 348 349fn execute_delete_to_line_end<D: EditorDocument>(doc: &mut D, range: Range) -> bool { 350 let range = range.normalize(); 351 let cursor = if range.is_caret() { 352 range.start 353 } else { 354 range.end 355 }; 356 let line_end = find_line_end(doc, cursor); 357 358 if cursor < line_end { 359 doc.delete(cursor..line_end); 360 } 361 362 doc.set_selection(None); 363 true 364} 365 366fn execute_undo<D: EditorDocument>(doc: &mut D) -> bool { 367 if doc.undo() { 368 let max = doc.len_chars(); 369 let cursor = doc.cursor(); 370 if cursor.offset > max { 371 doc.set_cursor_offset(max); 372 } 373 doc.set_selection(None); 374 true 375 } else { 376 false 377 } 378} 379 380fn execute_redo<D: EditorDocument>(doc: &mut D) -> bool { 381 if doc.redo() { 382 let max = doc.len_chars(); 383 let cursor = doc.cursor(); 384 if cursor.offset > max { 385 doc.set_cursor_offset(max); 386 } 387 doc.set_selection(None); 388 true 389 } else { 390 false 391 } 392} 393 394fn execute_toggle_format<D: EditorDocument>(doc: &mut D, marker: &str) -> bool { 395 let cursor_offset = doc.cursor_offset(); 396 let (start, end) = if let Some(sel) = doc.selection() { 397 (sel.start(), sel.end()) 398 } else { 399 find_word_boundaries(doc, cursor_offset) 400 }; 401 402 // Insert end marker first so start position stays valid. 403 doc.insert(end, marker); 404 doc.insert(start, marker); 405 doc.set_cursor_offset(end + marker.len() * 2); 406 doc.set_selection(None); 407 true 408} 409 410fn execute_insert_link<D: EditorDocument>(doc: &mut D) -> bool { 411 let cursor_offset = doc.cursor_offset(); 412 let (start, end) = if let Some(sel) = doc.selection() { 413 (sel.start(), sel.end()) 414 } else { 415 find_word_boundaries(doc, cursor_offset) 416 }; 417 418 // Insert [selected text](url) 419 doc.insert(end, "](url)"); 420 doc.insert(start, "["); 421 doc.set_cursor_offset(end + 8); 422 doc.set_selection(None); 423 true 424} 425 426/// Apply a formatting action to the document. 427/// 428/// Handles markdown formatting operations like bold, italic, headings, lists, etc. 429/// If there's a selection, formatting wraps it. Otherwise, behavior depends on the action: 430/// - Inline formats (Bold, Italic, etc.) expand to word boundaries 431/// - Block formats (Heading, Quote, List) operate on the current line 432pub fn apply_formatting<D: EditorDocument>(doc: &mut D, action: FormatAction) -> bool { 433 let cursor_offset = doc.cursor_offset(); 434 let (start, end) = if let Some(sel) = doc.selection() { 435 (sel.start(), sel.end()) 436 } else { 437 find_word_boundaries(doc, cursor_offset) 438 }; 439 440 match action { 441 FormatAction::Bold => { 442 doc.insert(end, "**"); 443 doc.insert(start, "**"); 444 doc.set_cursor_offset(end + 4); 445 doc.set_selection(None); 446 true 447 } 448 FormatAction::Italic => { 449 doc.insert(end, "*"); 450 doc.insert(start, "*"); 451 doc.set_cursor_offset(end + 2); 452 doc.set_selection(None); 453 true 454 } 455 FormatAction::Strikethrough => { 456 doc.insert(end, "~~"); 457 doc.insert(start, "~~"); 458 doc.set_cursor_offset(end + 4); 459 doc.set_selection(None); 460 true 461 } 462 FormatAction::Code => { 463 doc.insert(end, "`"); 464 doc.insert(start, "`"); 465 doc.set_cursor_offset(end + 2); 466 doc.set_selection(None); 467 true 468 } 469 FormatAction::Link => { 470 doc.insert(end, "](url)"); 471 doc.insert(start, "["); 472 doc.set_cursor_offset(end + 8); 473 doc.set_selection(None); 474 true 475 } 476 FormatAction::Image => { 477 doc.insert(end, "](url)"); 478 doc.insert(start, "!["); 479 doc.set_cursor_offset(end + 9); 480 doc.set_selection(None); 481 true 482 } 483 FormatAction::Heading(level) => { 484 let line_start = find_line_start(doc, cursor_offset); 485 let prefix = "#".repeat(level as usize) + " "; 486 let prefix_len = prefix.chars().count(); 487 doc.insert(line_start, &prefix); 488 doc.set_cursor_offset(cursor_offset + prefix_len); 489 doc.set_selection(None); 490 true 491 } 492 FormatAction::BulletList => { 493 if let Some(ctx) = detect_list_context(doc, cursor_offset) { 494 let continuation = match ctx { 495 ListContext::Unordered { indent, marker } => { 496 format!("\n{}{} ", indent, marker) 497 } 498 ListContext::Ordered { .. } => "\n\n - ".to_string(), 499 }; 500 let len = continuation.chars().count(); 501 doc.insert(cursor_offset, &continuation); 502 doc.set_cursor_offset(cursor_offset + len); 503 } else { 504 let line_start = find_line_start(doc, cursor_offset); 505 doc.insert(line_start, " - "); 506 doc.set_cursor_offset(cursor_offset + 3); 507 } 508 doc.set_selection(None); 509 true 510 } 511 FormatAction::NumberedList => { 512 if let Some(ctx) = detect_list_context(doc, cursor_offset) { 513 let continuation = match ctx { 514 ListContext::Unordered { .. } => "\n\n1. ".to_string(), 515 ListContext::Ordered { indent, number } => { 516 format!("\n{}{}. ", indent, number + 1) 517 } 518 }; 519 let len = continuation.chars().count(); 520 doc.insert(cursor_offset, &continuation); 521 doc.set_cursor_offset(cursor_offset + len); 522 } else { 523 let line_start = find_line_start(doc, cursor_offset); 524 doc.insert(line_start, "1. "); 525 doc.set_cursor_offset(cursor_offset + 3); 526 } 527 doc.set_selection(None); 528 true 529 } 530 FormatAction::Quote => { 531 let line_start = find_line_start(doc, cursor_offset); 532 doc.insert(line_start, "> "); 533 doc.set_cursor_offset(cursor_offset + 2); 534 doc.set_selection(None); 535 true 536 } 537 } 538} 539 540fn execute_select_all<D: EditorDocument>(doc: &mut D) -> bool { 541 let len = doc.len_chars(); 542 doc.set_selection(Some(Selection::new(0, len))); 543 doc.set_cursor_offset(len); 544 true 545} 546 547fn execute_move_cursor<D: EditorDocument>(doc: &mut D, offset: usize) -> bool { 548 let offset = offset.min(doc.len_chars()); 549 doc.set_cursor_offset(offset); 550 doc.set_selection(None); 551 true 552} 553 554fn execute_extend_selection<D: EditorDocument>(doc: &mut D, offset: usize) -> bool { 555 let offset = offset.min(doc.len_chars()); 556 let anchor = doc 557 .selection() 558 .map(|s| s.anchor) 559 .unwrap_or_else(|| doc.cursor_offset()); 560 doc.set_selection(Some(Selection::new(anchor, offset))); 561 doc.set_cursor_offset(offset); 562 true 563} 564 565/// Find word boundaries around cursor position. 566fn find_word_boundaries<D: EditorDocument>(doc: &D, offset: usize) -> (usize, usize) { 567 let len = doc.len_chars(); 568 569 // Find start by scanning backwards. 570 let mut start = 0; 571 for i in (0..offset).rev() { 572 match doc.char_at(i) { 573 Some(c) if c.is_whitespace() => { 574 start = i + 1; 575 break; 576 } 577 Some(_) => continue, 578 None => break, 579 } 580 } 581 582 // Find end by scanning forwards. 583 let mut end = len; 584 for i in offset..len { 585 match doc.char_at(i) { 586 Some(c) if c.is_whitespace() => { 587 end = i; 588 break; 589 } 590 Some(_) => continue, 591 None => break, 592 } 593 } 594 595 (start, end) 596} 597 598/// Generate list continuation text. 599fn list_continuation(ctx: &ListContext) -> String { 600 match ctx { 601 ListContext::Unordered { indent, marker } => { 602 format!("\n{}{} ", indent, marker) 603 } 604 ListContext::Ordered { indent, number } => { 605 format!("\n{}{}. ", indent, number + 1) 606 } 607 } 608} 609 610// === Keydown handling === 611 612use crate::actions::{KeyCombo, KeybindingConfig, KeydownResult}; 613 614/// Handle a keydown event using the keybinding configuration. 615/// 616/// This handles keyboard shortcuts only. Text input and deletion 617/// are handled by beforeinput. Navigation (arrows, etc.) is passed 618/// through to the browser/platform. 619/// 620/// For clipboard operations, use [`handle_keydown_with_clipboard`] instead. 621pub fn handle_keydown<D: EditorDocument>( 622 doc: &mut D, 623 config: &KeybindingConfig, 624 combo: KeyCombo, 625 range: Range, 626) -> KeydownResult { 627 // Look up keybinding (range is applied by lookup). 628 if let Some(action) = config.lookup(&combo, range) { 629 execute_action(doc, &action); 630 return KeydownResult::Handled; 631 } 632 633 check_passthrough(&combo) 634} 635 636/// Handle a keydown event with clipboard support. 637/// 638/// Like [`handle_keydown`], but uses the provided clipboard platform 639/// for clipboard operations (Cut, Copy, Paste, CopyAsHtml). 640pub fn handle_keydown_with_clipboard<D, P>( 641 doc: &mut D, 642 config: &KeybindingConfig, 643 combo: KeyCombo, 644 range: Range, 645 clipboard: &P, 646) -> KeydownResult 647where 648 D: EditorDocument, 649 P: ClipboardPlatform, 650{ 651 // Look up keybinding (range is applied by lookup). 652 if let Some(action) = config.lookup(&combo, range) { 653 execute_action_with_clipboard(doc, &action, clipboard); 654 return KeydownResult::Handled; 655 } 656 657 check_passthrough(&combo) 658} 659 660/// Check if a key combo should pass through to the platform. 661fn check_passthrough(combo: &KeyCombo) -> KeydownResult { 662 // Navigation keys should pass through. 663 if combo.key.is_navigation() { 664 return KeydownResult::PassThrough; 665 } 666 667 // Modifier-only keypresses should pass through. 668 if combo.key.is_modifier() { 669 return KeydownResult::PassThrough; 670 } 671 672 // Content keys (typing, backspace, etc.) - let beforeinput handle. 673 KeydownResult::NotHandled 674} 675 676#[cfg(test)] 677mod tests { 678 use super::*; 679 use crate::{EditorRope, PlainEditor, UndoableBuffer}; 680 681 type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>; 682 683 fn make_editor(content: &str) -> TestEditor { 684 let rope = EditorRope::from_str(content); 685 let buf = UndoableBuffer::new(rope, 100); 686 PlainEditor::new(buf) 687 } 688 689 #[test] 690 fn test_insert() { 691 let mut editor = make_editor("hello"); 692 let action = EditorAction::Insert { 693 text: " world".to_string(), 694 range: Range::caret(5), 695 }; 696 assert!(execute_action(&mut editor, &action)); 697 assert_eq!(editor.content_string(), "hello world"); 698 } 699 700 #[test] 701 fn test_delete_backward() { 702 let mut editor = make_editor("hello"); 703 editor.set_cursor_offset(5); 704 let action = EditorAction::DeleteBackward { 705 range: Range::caret(5), 706 }; 707 assert!(execute_action(&mut editor, &action)); 708 assert_eq!(editor.content_string(), "hell"); 709 } 710 711 #[test] 712 fn test_delete_selection() { 713 let mut editor = make_editor("hello world"); 714 editor.set_selection(Some(Selection::new(5, 11))); 715 let action = EditorAction::DeleteBackward { 716 range: Range::new(5, 11), 717 }; 718 assert!(execute_action(&mut editor, &action)); 719 assert_eq!(editor.content_string(), "hello"); 720 } 721 722 #[test] 723 fn test_undo_redo() { 724 let mut editor = make_editor("hello"); 725 726 let action = EditorAction::Insert { 727 text: " world".to_string(), 728 range: Range::caret(5), 729 }; 730 execute_action(&mut editor, &action); 731 assert_eq!(editor.content_string(), "hello world"); 732 733 assert!(execute_action(&mut editor, &EditorAction::Undo)); 734 assert_eq!(editor.content_string(), "hello"); 735 736 assert!(execute_action(&mut editor, &EditorAction::Redo)); 737 assert_eq!(editor.content_string(), "hello world"); 738 } 739 740 #[test] 741 fn test_select_all() { 742 let mut editor = make_editor("hello world"); 743 assert!(execute_action(&mut editor, &EditorAction::SelectAll)); 744 let sel = editor.selection().unwrap(); 745 assert_eq!(sel.start(), 0); 746 assert_eq!(sel.end(), 11); 747 } 748 749 #[test] 750 fn test_toggle_bold() { 751 let mut editor = make_editor("hello"); 752 editor.set_selection(Some(Selection::new(0, 5))); 753 assert!(execute_action(&mut editor, &EditorAction::ToggleBold)); 754 assert_eq!(editor.content_string(), "**hello**"); 755 } 756}