atproto blogging
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}