atproto blogging
1//! Core editor document trait and implementations.
2//!
3//! Defines the `EditorDocument` trait for abstracting editor behavior,
4//! allowing different storage strategies (plain fields vs Signals) while
5//! sharing the core editing logic.
6
7use std::ops::Range;
8
9use smol_str::SmolStr;
10use web_time::Instant;
11
12use crate::text::TextBuffer;
13use crate::types::{BLOCK_SYNTAX_ZONE, CompositionState, CursorState, EditInfo, Selection};
14use crate::undo::UndoManager;
15
16/// Core trait for editor documents.
17///
18/// Defines the interface for any editor implementation. Different backends
19/// can implement this trait with different storage strategies:
20/// - `PlainEditor<T>`: Simple field-based storage
21/// - Reactive implementations: Use Signals/state management
22///
23/// The trait is generic over the buffer type, which must implement both
24/// `TextBuffer` (for text operations) and `UndoManager` (for undo/redo).
25pub trait EditorDocument {
26 /// The buffer type used for text storage and undo.
27 type Buffer: TextBuffer + UndoManager;
28
29 // === Required: Buffer access ===
30
31 /// Get a reference to the underlying buffer.
32 fn buffer(&self) -> &Self::Buffer;
33
34 /// Get a mutable reference to the underlying buffer.
35 fn buffer_mut(&mut self) -> &mut Self::Buffer;
36
37 // === Required: Cursor/selection state ===
38
39 /// Get the current cursor state.
40 fn cursor(&self) -> CursorState;
41
42 /// Set the cursor state.
43 fn set_cursor(&mut self, cursor: CursorState);
44
45 /// Get the current selection, if any.
46 fn selection(&self) -> Option<Selection>;
47
48 /// Set the selection.
49 fn set_selection(&mut self, selection: Option<Selection>);
50
51 // === Required: Edit tracking ===
52
53 /// Get the last edit info, if any.
54 fn last_edit(&self) -> Option<EditInfo>;
55
56 /// Set the last edit info.
57 fn set_last_edit(&mut self, edit: Option<EditInfo>);
58
59 // === Required: Composition (IME) state ===
60
61 /// Get the current composition state.
62 fn composition(&self) -> Option<CompositionState>;
63
64 /// Set the composition state.
65 fn set_composition(&mut self, composition: Option<CompositionState>);
66
67 /// Get the timestamp when composition last ended (Safari timing workaround).
68 ///
69 /// Returns None if composition never ended or implementation doesn't track it.
70 fn composition_ended_at(&self) -> Option<web_time::Instant>;
71
72 /// Record that composition ended now (Safari timing workaround).
73 ///
74 /// Implementations that don't need Safari workarounds can make this a no-op.
75 fn set_composition_ended_now(&mut self);
76
77 // === Required: Cursor snap hint ===
78
79 /// Get the pending snap direction hint.
80 ///
81 /// This hints which direction the cursor should snap after an edit
82 /// when the cursor lands on invisible syntax. Forward for insertions
83 /// (snap toward new content), backward for deletions (snap toward
84 /// remaining content).
85 fn pending_snap(&self) -> Option<crate::SnapDirection>;
86
87 /// Set the pending snap direction hint.
88 fn set_pending_snap(&mut self, snap: Option<crate::SnapDirection>);
89
90 // === Provided: Convenience accessors ===
91
92 /// Get the cursor offset.
93 fn cursor_offset(&self) -> usize {
94 self.cursor().offset
95 }
96
97 /// Set just the cursor offset, preserving other cursor state.
98 fn set_cursor_offset(&mut self, offset: usize) {
99 let mut cursor = self.cursor();
100 cursor.offset = offset;
101 self.set_cursor(cursor);
102 }
103
104 /// Get the full content as a String.
105 fn content_string(&self) -> String {
106 self.buffer().to_string()
107 }
108
109 /// Get length in characters.
110 fn len_chars(&self) -> usize {
111 self.buffer().len_chars()
112 }
113
114 /// Get length in bytes.
115 fn len_bytes(&self) -> usize {
116 self.buffer().len_bytes()
117 }
118
119 /// Check if document is empty.
120 fn is_empty(&self) -> bool {
121 self.buffer().len_chars() == 0
122 }
123
124 /// Get a slice of the content.
125 fn slice(&self, range: Range<usize>) -> Option<SmolStr> {
126 self.buffer().slice(range)
127 }
128
129 /// Get character at offset.
130 fn char_at(&self, offset: usize) -> Option<char> {
131 self.buffer().char_at(offset)
132 }
133
134 /// Convert char offset to byte offset.
135 fn char_to_byte(&self, char_offset: usize) -> usize {
136 self.buffer().char_to_byte(char_offset)
137 }
138
139 /// Convert byte offset to char offset.
140 fn byte_to_char(&self, byte_offset: usize) -> usize {
141 self.buffer().byte_to_char(byte_offset)
142 }
143
144 /// Get selected text, if any.
145 fn selected_text(&self) -> Option<SmolStr> {
146 self.selection()
147 .and_then(|sel| self.buffer().slice(sel.to_range()))
148 }
149
150 // === Provided: Text operations ===
151
152 /// Insert text at char offset, returning edit info.
153 fn insert(&mut self, offset: usize, text: &str) -> EditInfo {
154 let contains_newline = text.contains('\n');
155 let in_block_syntax_zone = self.is_in_block_syntax_zone(offset);
156
157 self.buffer_mut().insert(offset, text);
158
159 let inserted_len = text.chars().count();
160 self.set_cursor_offset(offset + inserted_len);
161
162 let edit = EditInfo {
163 edit_char_pos: offset,
164 inserted_len,
165 deleted_len: 0,
166 contains_newline,
167 in_block_syntax_zone,
168 doc_len_after: self.buffer().len_chars(),
169 timestamp: Instant::now(),
170 };
171
172 self.set_last_edit(Some(edit.clone()));
173 edit
174 }
175
176 /// Delete char range, returning edit info.
177 fn delete(&mut self, range: Range<usize>) -> EditInfo {
178 let deleted_text = self.buffer().slice(range.clone());
179 let contains_newline = deleted_text
180 .as_ref()
181 .map(|s| s.contains('\n'))
182 .unwrap_or(false);
183 let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start);
184 let deleted_len = range.end - range.start;
185
186 self.buffer_mut().delete(range.clone());
187 self.set_cursor_offset(range.start);
188
189 let edit = EditInfo {
190 edit_char_pos: range.start,
191 inserted_len: 0,
192 deleted_len,
193 contains_newline,
194 in_block_syntax_zone,
195 doc_len_after: self.buffer().len_chars(),
196 timestamp: Instant::now(),
197 };
198
199 self.set_last_edit(Some(edit.clone()));
200 edit
201 }
202
203 /// Replace char range with text, returning edit info.
204 fn replace(&mut self, range: Range<usize>, text: &str) -> EditInfo {
205 let deleted_text = self.buffer().slice(range.clone());
206 let deleted_contains_newline = deleted_text
207 .as_ref()
208 .map(|s| s.contains('\n'))
209 .unwrap_or(false);
210 let contains_newline = text.contains('\n') || deleted_contains_newline;
211 let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start);
212 let deleted_len = range.end - range.start;
213
214 self.buffer_mut().delete(range.clone());
215 self.buffer_mut().insert(range.start, text);
216
217 let inserted_len = text.chars().count();
218 self.set_cursor_offset(range.start + inserted_len);
219
220 let edit = EditInfo {
221 edit_char_pos: range.start,
222 inserted_len,
223 deleted_len,
224 contains_newline,
225 in_block_syntax_zone,
226 doc_len_after: self.buffer().len_chars(),
227 timestamp: Instant::now(),
228 };
229
230 self.set_last_edit(Some(edit.clone()));
231 edit
232 }
233
234 /// Append text at end of document.
235 ///
236 /// This is a fast path for appending - delegates to buffer's push()
237 /// which may have an optimized implementation.
238 fn push(&mut self, text: &str) -> EditInfo {
239 let offset = self.buffer().len_chars();
240 let contains_newline = text.contains('\n');
241 let in_block_syntax_zone = self.is_in_block_syntax_zone(offset);
242
243 self.buffer_mut().push(text);
244
245 let inserted_len = text.chars().count();
246 self.set_cursor_offset(offset + inserted_len);
247
248 let edit = EditInfo {
249 edit_char_pos: offset,
250 inserted_len,
251 deleted_len: 0,
252 contains_newline,
253 in_block_syntax_zone,
254 doc_len_after: self.buffer().len_chars(),
255 timestamp: Instant::now(),
256 };
257
258 self.set_last_edit(Some(edit.clone()));
259 edit
260 }
261
262 /// Delete the current selection, if any.
263 fn delete_selection(&mut self) -> Option<EditInfo> {
264 let sel = self.selection()?;
265 self.set_selection(None);
266 if sel.is_collapsed() {
267 return None;
268 }
269 Some(self.delete(sel.to_range()))
270 }
271
272 // === Provided: Undo/Redo ===
273
274 fn undo(&mut self) -> bool {
275 self.buffer_mut().undo()
276 }
277
278 fn redo(&mut self) -> bool {
279 self.buffer_mut().redo()
280 }
281
282 fn can_undo(&self) -> bool {
283 self.buffer().can_undo()
284 }
285
286 fn can_redo(&self) -> bool {
287 self.buffer().can_redo()
288 }
289
290 fn clear_history(&mut self) {
291 self.buffer_mut().clear_history();
292 }
293
294 // === Provided: Helpers ===
295
296 /// Check if offset is in the block-syntax zone (first ~6 chars of line).
297 fn is_in_block_syntax_zone(&self, offset: usize) -> bool {
298 let mut line_start = offset;
299 while line_start > 0 {
300 if let Some('\n') = self.buffer().char_at(line_start - 1) {
301 break;
302 }
303 line_start -= 1;
304 }
305 offset - line_start < BLOCK_SYNTAX_ZONE
306 }
307}
308
309/// Simple field-based implementation of EditorDocument.
310///
311/// Stores cursor, selection, and edit state as plain fields.
312/// Use this for non-reactive contexts or as a base for testing.
313#[derive(Clone)]
314pub struct PlainEditor<T: TextBuffer + UndoManager> {
315 buffer: T,
316 cursor: CursorState,
317 selection: Option<Selection>,
318 last_edit: Option<EditInfo>,
319 composition: Option<CompositionState>,
320 composition_ended_at: Option<web_time::Instant>,
321 pending_snap: Option<crate::SnapDirection>,
322}
323
324impl<T: TextBuffer + UndoManager + Default> Default for PlainEditor<T> {
325 fn default() -> Self {
326 Self::new(T::default())
327 }
328}
329
330impl<T: TextBuffer + UndoManager> PlainEditor<T> {
331 /// Create a new editor with the given buffer.
332 pub fn new(buffer: T) -> Self {
333 Self {
334 buffer,
335 cursor: CursorState::default(),
336 selection: None,
337 last_edit: None,
338 composition: None,
339 composition_ended_at: None,
340 pending_snap: None,
341 }
342 }
343
344 /// Get direct access to the inner buffer (bypasses trait).
345 pub fn inner(&self) -> &T {
346 &self.buffer
347 }
348
349 /// Get direct mutable access to the inner buffer (bypasses trait).
350 pub fn inner_mut(&mut self) -> &mut T {
351 &mut self.buffer
352 }
353}
354
355impl<T: TextBuffer + UndoManager> EditorDocument for PlainEditor<T> {
356 type Buffer = T;
357
358 fn buffer(&self) -> &Self::Buffer {
359 &self.buffer
360 }
361
362 fn buffer_mut(&mut self) -> &mut Self::Buffer {
363 &mut self.buffer
364 }
365
366 fn cursor(&self) -> CursorState {
367 self.cursor.clone()
368 }
369
370 fn set_cursor(&mut self, cursor: CursorState) {
371 self.cursor = cursor;
372 }
373
374 fn selection(&self) -> Option<Selection> {
375 self.selection.clone()
376 }
377
378 fn set_selection(&mut self, selection: Option<Selection>) {
379 self.selection = selection;
380 }
381
382 fn last_edit(&self) -> Option<EditInfo> {
383 self.last_edit.clone()
384 }
385
386 fn set_last_edit(&mut self, edit: Option<EditInfo>) {
387 self.last_edit = edit;
388 }
389
390 fn composition(&self) -> Option<CompositionState> {
391 self.composition.clone()
392 }
393
394 fn set_composition(&mut self, composition: Option<CompositionState>) {
395 self.composition = composition;
396 }
397
398 fn composition_ended_at(&self) -> Option<web_time::Instant> {
399 self.composition_ended_at
400 }
401
402 fn set_composition_ended_now(&mut self) {
403 self.composition_ended_at = Some(web_time::Instant::now());
404 }
405
406 fn pending_snap(&self) -> Option<crate::SnapDirection> {
407 self.pending_snap
408 }
409
410 fn set_pending_snap(&mut self, snap: Option<crate::SnapDirection>) {
411 self.pending_snap = snap;
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use crate::{EditorRope, UndoableBuffer};
419
420 type TestEditor = PlainEditor<UndoableBuffer<EditorRope>>;
421
422 fn make_editor(content: &str) -> TestEditor {
423 let rope = EditorRope::from_str(content);
424 let buf = UndoableBuffer::new(rope, 100);
425 PlainEditor::new(buf)
426 }
427
428 #[test]
429 fn test_basic_insert() {
430 let mut editor = make_editor("hello");
431 assert_eq!(editor.content_string(), "hello");
432
433 let edit = editor.insert(5, " world");
434 assert_eq!(editor.content_string(), "hello world");
435 assert_eq!(edit.inserted_len, 6);
436 assert_eq!(editor.cursor_offset(), 11);
437 }
438
439 #[test]
440 fn test_delete() {
441 let mut editor = make_editor("hello world");
442
443 let edit = editor.delete(5..11);
444 assert_eq!(editor.content_string(), "hello");
445 assert_eq!(edit.deleted_len, 6);
446 assert_eq!(editor.cursor_offset(), 5);
447 }
448
449 #[test]
450 fn test_replace() {
451 let mut editor = make_editor("hello world");
452
453 let edit = editor.replace(6..11, "rust");
454 assert_eq!(editor.content_string(), "hello rust");
455 assert_eq!(edit.deleted_len, 5);
456 assert_eq!(edit.inserted_len, 4);
457 }
458
459 #[test]
460 fn test_undo_redo() {
461 let mut editor = make_editor("hello");
462
463 editor.insert(5, " world");
464 assert_eq!(editor.content_string(), "hello world");
465
466 assert!(editor.undo());
467 assert_eq!(editor.content_string(), "hello");
468
469 assert!(editor.redo());
470 assert_eq!(editor.content_string(), "hello world");
471 }
472
473 #[test]
474 fn test_selection() {
475 let mut editor = make_editor("hello world");
476
477 editor.set_selection(Some(Selection::new(0, 5)));
478 assert_eq!(editor.selected_text(), Some("hello".into()));
479
480 let edit = editor.delete_selection();
481 assert!(edit.is_some());
482 assert_eq!(editor.content_string(), " world");
483 assert!(editor.selection().is_none());
484 }
485
486 #[test]
487 fn test_block_syntax_zone() {
488 let mut editor = make_editor("# heading\nparagraph");
489
490 // Position 0 is in block syntax zone
491 let edit = editor.insert(0, "x");
492 assert!(edit.in_block_syntax_zone);
493
494 // Position after newline (start of "paragraph") is also in zone
495 // Original was "# heading\nparagraph", after insert "x# heading\nparagraph"
496 // Position 11 is start of "paragraph" line
497 let edit = editor.insert(11, "y");
498 assert!(edit.in_block_syntax_zone);
499 }
500
501 #[test]
502 fn test_composition_state() {
503 let mut editor = make_editor("hello");
504
505 assert!(editor.composition().is_none());
506
507 let comp = CompositionState::new(5, "わ".into());
508 editor.set_composition(Some(comp.clone()));
509
510 assert_eq!(editor.composition(), Some(comp));
511
512 editor.set_composition(None);
513 assert!(editor.composition().is_none());
514 }
515
516 #[test]
517 fn test_offset_conversions() {
518 let editor = make_editor("héllo wörld"); // multi-byte chars
519
520 // 'é' is 2 bytes, 'ö' is 2 bytes
521 // chars: h é l l o w ö r l d
522 // idx: 0 1 2 3 4 5 6 7 8 9 10
523
524 assert_eq!(editor.len_chars(), 11);
525 assert!(editor.len_bytes() > 11); // multi-byte chars
526
527 // char 1 ('é') starts at byte 1
528 assert_eq!(editor.char_to_byte(1), 1);
529 // char 2 ('l') starts after 'é' (2 bytes)
530 assert_eq!(editor.char_to_byte(2), 3);
531 }
532}