Skip to main content

text_document/
cursor.rs

1//! TextCursor implementation — Qt-style multi-cursor with automatic position adjustment.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use anyhow::Result;
8
9use frontend::commands::{
10    document_editing_commands, document_formatting_commands, document_inspection_commands,
11    inline_element_commands, undo_redo_commands,
12};
13use crate::ListStyle;
14
15use unicode_segmentation::UnicodeSegmentation;
16
17use crate::convert::{to_i64, to_usize};
18use crate::events::DocumentEvent;
19use crate::fragment::DocumentFragment;
20use crate::inner::{CursorData, TextDocumentInner};
21use crate::{BlockFormat, FrameFormat, MoveMode, MoveOperation, SelectionType, TextFormat};
22
23/// A cursor into a [`TextDocument`](crate::TextDocument).
24///
25/// Multiple cursors can coexist on the same document (like Qt's `QTextCursor`).
26/// When any cursor edits text, all other cursors' positions are automatically
27/// adjusted by the document.
28///
29/// Cloning a cursor creates an **independent** cursor at the same position.
30pub struct TextCursor {
31    pub(crate) doc: Arc<Mutex<TextDocumentInner>>,
32    pub(crate) data: Arc<Mutex<CursorData>>,
33}
34
35impl Clone for TextCursor {
36    fn clone(&self) -> Self {
37        let (position, anchor) = {
38            let d = self.data.lock();
39            (d.position, d.anchor)
40        };
41        let data = {
42            let mut inner = self.doc.lock();
43            let data = Arc::new(Mutex::new(CursorData { position, anchor }));
44            inner.cursors.push(Arc::downgrade(&data));
45            data
46        };
47        TextCursor {
48            doc: self.doc.clone(),
49            data,
50        }
51    }
52}
53
54impl TextCursor {
55    // ── Helpers (called while doc lock is NOT held) ──────────
56
57    fn read_cursor(&self) -> (usize, usize) {
58        let d = self.data.lock();
59        (d.position, d.anchor)
60    }
61
62    /// Common post-edit bookkeeping: adjust all cursors, set this cursor to
63    /// `new_pos`, mark modified, invalidate text cache, queue a
64    /// `ContentsChanged` event, and return the queued events for dispatch.
65    fn finish_edit(
66        &self,
67        inner: &mut TextDocumentInner,
68        edit_pos: usize,
69        removed: usize,
70        new_pos: usize,
71        blocks_affected: usize,
72    ) -> Vec<(DocumentEvent, Vec<Arc<dyn Fn(DocumentEvent) + Send + Sync>>)> {
73        let added = new_pos - edit_pos;
74        inner.adjust_cursors(edit_pos, removed, added);
75        {
76            let mut d = self.data.lock();
77            d.position = new_pos;
78            d.anchor = new_pos;
79        }
80        inner.modified = true;
81        inner.invalidate_text_cache();
82        inner.queue_event(DocumentEvent::ContentsChanged {
83            position: edit_pos,
84            chars_removed: removed,
85            chars_added: added,
86            blocks_affected,
87        });
88        inner.check_block_count_changed();
89        self.queue_undo_redo_event(inner)
90    }
91
92    // ── Position & selection ─────────────────────────────────
93
94    /// Current cursor position (between characters).
95    pub fn position(&self) -> usize {
96        self.data.lock().position
97    }
98
99    /// Anchor position. Equal to `position()` when no selection.
100    pub fn anchor(&self) -> usize {
101        self.data.lock().anchor
102    }
103
104    /// Returns true if there is a selection.
105    pub fn has_selection(&self) -> bool {
106        let d = self.data.lock();
107        d.position != d.anchor
108    }
109
110    /// Start of the selection (min of position and anchor).
111    pub fn selection_start(&self) -> usize {
112        let d = self.data.lock();
113        d.position.min(d.anchor)
114    }
115
116    /// End of the selection (max of position and anchor).
117    pub fn selection_end(&self) -> usize {
118        let d = self.data.lock();
119        d.position.max(d.anchor)
120    }
121
122    /// Get the selected text. Returns empty string if no selection.
123    pub fn selected_text(&self) -> Result<String> {
124        let (pos, anchor) = self.read_cursor();
125        if pos == anchor {
126            return Ok(String::new());
127        }
128        let start = pos.min(anchor);
129        let len = pos.max(anchor) - start;
130        let inner = self.doc.lock();
131        let dto = frontend::document_inspection::GetTextAtPositionDto {
132            position: to_i64(start),
133            length: to_i64(len),
134        };
135        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
136        Ok(result.text)
137    }
138
139    /// Collapse the selection by moving anchor to position.
140    pub fn clear_selection(&self) {
141        let mut d = self.data.lock();
142        d.anchor = d.position;
143    }
144
145    // ── Boundary queries ─────────────────────────────────────
146
147    /// True if the cursor is at the start of a block.
148    pub fn at_block_start(&self) -> bool {
149        let pos = self.position();
150        let inner = self.doc.lock();
151        let dto = frontend::document_inspection::GetBlockAtPositionDto {
152            position: to_i64(pos),
153        };
154        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
155            pos == to_usize(info.block_start)
156        } else {
157            false
158        }
159    }
160
161    /// True if the cursor is at the end of a block.
162    pub fn at_block_end(&self) -> bool {
163        let pos = self.position();
164        let inner = self.doc.lock();
165        let dto = frontend::document_inspection::GetBlockAtPositionDto {
166            position: to_i64(pos),
167        };
168        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
169            pos == to_usize(info.block_start) + to_usize(info.block_length)
170        } else {
171            false
172        }
173    }
174
175    /// True if the cursor is at position 0.
176    pub fn at_start(&self) -> bool {
177        self.data.lock().position == 0
178    }
179
180    /// True if the cursor is at the very end of the document.
181    pub fn at_end(&self) -> bool {
182        let pos = self.position();
183        let inner = self.doc.lock();
184        let stats =
185            document_inspection_commands::get_document_stats(&inner.ctx).unwrap_or({
186                frontend::document_inspection::DocumentStatsDto {
187                    character_count: 0,
188                    word_count: 0,
189                    block_count: 0,
190                    frame_count: 0,
191                    image_count: 0,
192                    list_count: 0,
193                }
194            });
195        pos >= to_usize(stats.character_count)
196    }
197
198    /// The block number (0-indexed) containing the cursor.
199    pub fn block_number(&self) -> usize {
200        let pos = self.position();
201        let inner = self.doc.lock();
202        let dto = frontend::document_inspection::GetBlockAtPositionDto {
203            position: to_i64(pos),
204        };
205        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
206            .map(|info| to_usize(info.block_number))
207            .unwrap_or(0)
208    }
209
210    /// The cursor's column within the current block (0-indexed).
211    pub fn position_in_block(&self) -> usize {
212        let pos = self.position();
213        let inner = self.doc.lock();
214        let dto = frontend::document_inspection::GetBlockAtPositionDto {
215            position: to_i64(pos),
216        };
217        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
218            .map(|info| pos.saturating_sub(to_usize(info.block_start)))
219            .unwrap_or(0)
220    }
221
222    // ── Movement ─────────────────────────────────────────────
223
224    /// Set the cursor to an absolute position.
225    pub fn set_position(&self, position: usize, mode: MoveMode) {
226        // Clamp to document length
227        let end = {
228            let inner = self.doc.lock();
229            document_inspection_commands::get_document_stats(&inner.ctx)
230                .map(|s| to_usize(s.character_count))
231                .unwrap_or(0)
232        };
233        let pos = position.min(end);
234        let mut d = self.data.lock();
235        d.position = pos;
236        if mode == MoveMode::MoveAnchor {
237            d.anchor = pos;
238        }
239    }
240
241    /// Move the cursor by a semantic operation.
242    ///
243    /// `n` is used as a repeat count for character-level movements
244    /// (`NextCharacter`, `PreviousCharacter`, `Left`, `Right`).
245    /// For all other operations it is ignored. Returns `true` if the cursor moved.
246    pub fn move_position(&self, operation: MoveOperation, mode: MoveMode, n: usize) -> bool {
247        let old_pos = self.position();
248        let target = self.resolve_move(operation, n);
249        self.set_position(target, mode);
250        self.position() != old_pos
251    }
252
253    /// Select a region relative to the cursor position.
254    pub fn select(&self, selection: SelectionType) {
255        match selection {
256            SelectionType::Document => {
257                let end = {
258                    let inner = self.doc.lock();
259                    document_inspection_commands::get_document_stats(&inner.ctx)
260                        .map(|s| to_usize(s.character_count))
261                        .unwrap_or(0)
262                };
263                let mut d = self.data.lock();
264                d.anchor = 0;
265                d.position = end;
266            }
267            SelectionType::BlockUnderCursor | SelectionType::LineUnderCursor => {
268                let pos = self.position();
269                let inner = self.doc.lock();
270                let dto = frontend::document_inspection::GetBlockAtPositionDto {
271                    position: to_i64(pos),
272                };
273                if let Ok(info) =
274                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
275                {
276                    let start = to_usize(info.block_start);
277                    let end = start + to_usize(info.block_length);
278                    drop(inner);
279                    let mut d = self.data.lock();
280                    d.anchor = start;
281                    d.position = end;
282                }
283            }
284            SelectionType::WordUnderCursor => {
285                let pos = self.position();
286                let (word_start, word_end) = self.find_word_boundaries(pos);
287                let mut d = self.data.lock();
288                d.anchor = word_start;
289                d.position = word_end;
290            }
291        }
292    }
293
294    // ── Text editing ─────────────────────────────────────────
295
296    /// Insert plain text at the cursor. Replaces selection if any.
297    pub fn insert_text(&self, text: &str) -> Result<()> {
298        let (pos, anchor) = self.read_cursor();
299
300        // Try direct insert first (handles same-block selection and no-selection cases)
301        let dto = frontend::document_editing::InsertTextDto {
302            position: to_i64(pos),
303            anchor: to_i64(anchor),
304            text: text.into(),
305        };
306
307        let queued = {
308            let mut inner = self.doc.lock();
309            let result = match document_editing_commands::insert_text(
310                &inner.ctx,
311                Some(inner.stack_id),
312                &dto,
313            ) {
314                Ok(r) => r,
315                Err(_) if pos != anchor => {
316                    // Cross-block selection: compose delete + insert as a single undo unit
317                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
318
319                    let del_dto = frontend::document_editing::DeleteTextDto {
320                        position: to_i64(pos),
321                        anchor: to_i64(anchor),
322                    };
323                    let del_result = document_editing_commands::delete_text(
324                        &inner.ctx,
325                        Some(inner.stack_id),
326                        &del_dto,
327                    )?;
328                    let del_pos = to_usize(del_result.new_position);
329
330                    let ins_dto = frontend::document_editing::InsertTextDto {
331                        position: to_i64(del_pos),
332                        anchor: to_i64(del_pos),
333                        text: text.into(),
334                    };
335                    let ins_result = document_editing_commands::insert_text(
336                        &inner.ctx,
337                        Some(inner.stack_id),
338                        &ins_dto,
339                    )?;
340
341                    undo_redo_commands::end_composite(&inner.ctx);
342                    ins_result
343                }
344                Err(e) => return Err(e),
345            };
346
347            let edit_pos = pos.min(anchor);
348            let removed = pos.max(anchor) - edit_pos;
349            self.finish_edit(
350                &mut inner,
351                edit_pos,
352                removed,
353                to_usize(result.new_position),
354                to_usize(result.blocks_affected),
355            )
356        };
357        crate::inner::dispatch_queued_events(queued);
358        Ok(())
359    }
360
361    /// Insert text with a specific character format. Replaces selection if any.
362    pub fn insert_formatted_text(&self, text: &str, format: &TextFormat) -> Result<()> {
363        let (pos, anchor) = self.read_cursor();
364        let queued = {
365            let mut inner = self.doc.lock();
366            let dto = frontend::document_editing::InsertFormattedTextDto {
367                position: to_i64(pos),
368                anchor: to_i64(anchor),
369                text: text.into(),
370                font_family: format.font_family.clone().unwrap_or_default(),
371                font_point_size: format.font_point_size.map(|v| v as i64).unwrap_or(0),
372                font_bold: format.font_bold.unwrap_or(false),
373                font_italic: format.font_italic.unwrap_or(false),
374                font_underline: format.font_underline.unwrap_or(false),
375                font_strikeout: format.font_strikeout.unwrap_or(false),
376            };
377            let result = document_editing_commands::insert_formatted_text(
378                &inner.ctx,
379                Some(inner.stack_id),
380                &dto,
381            )?;
382            let edit_pos = pos.min(anchor);
383            let removed = pos.max(anchor) - edit_pos;
384            self.finish_edit(&mut inner, edit_pos, removed, to_usize(result.new_position), 1)
385        };
386        crate::inner::dispatch_queued_events(queued);
387        Ok(())
388    }
389
390    /// Insert a block break (new paragraph). Replaces selection if any.
391    pub fn insert_block(&self) -> Result<()> {
392        let (pos, anchor) = self.read_cursor();
393        let queued = {
394            let mut inner = self.doc.lock();
395            let dto = frontend::document_editing::InsertBlockDto {
396                position: to_i64(pos),
397                anchor: to_i64(anchor),
398            };
399            let result =
400                document_editing_commands::insert_block(&inner.ctx, Some(inner.stack_id), &dto)?;
401            let edit_pos = pos.min(anchor);
402            let removed = pos.max(anchor) - edit_pos;
403            self.finish_edit(&mut inner, edit_pos, removed, to_usize(result.new_position), 2)
404        };
405        crate::inner::dispatch_queued_events(queued);
406        Ok(())
407    }
408
409    /// Insert an HTML fragment at the cursor position. Replaces selection if any.
410    pub fn insert_html(&self, html: &str) -> Result<()> {
411        let (pos, anchor) = self.read_cursor();
412        let queued = {
413            let mut inner = self.doc.lock();
414            let dto = frontend::document_editing::InsertHtmlAtPositionDto {
415                position: to_i64(pos),
416                anchor: to_i64(anchor),
417                html: html.into(),
418            };
419            let result = document_editing_commands::insert_html_at_position(
420                &inner.ctx,
421                Some(inner.stack_id),
422                &dto,
423            )?;
424            let edit_pos = pos.min(anchor);
425            let removed = pos.max(anchor) - edit_pos;
426            self.finish_edit(
427                &mut inner,
428                edit_pos,
429                removed,
430                to_usize(result.new_position),
431                to_usize(result.blocks_added),
432            )
433        };
434        crate::inner::dispatch_queued_events(queued);
435        Ok(())
436    }
437
438    /// Insert a Markdown fragment at the cursor position. Replaces selection if any.
439    pub fn insert_markdown(&self, markdown: &str) -> Result<()> {
440        let (pos, anchor) = self.read_cursor();
441        let queued = {
442            let mut inner = self.doc.lock();
443            let dto = frontend::document_editing::InsertMarkdownAtPositionDto {
444                position: to_i64(pos),
445                anchor: to_i64(anchor),
446                markdown: markdown.into(),
447            };
448            let result = document_editing_commands::insert_markdown_at_position(
449                &inner.ctx,
450                Some(inner.stack_id),
451                &dto,
452            )?;
453            let edit_pos = pos.min(anchor);
454            let removed = pos.max(anchor) - edit_pos;
455            self.finish_edit(
456                &mut inner,
457                edit_pos,
458                removed,
459                to_usize(result.new_position),
460                to_usize(result.blocks_added),
461            )
462        };
463        crate::inner::dispatch_queued_events(queued);
464        Ok(())
465    }
466
467    /// Insert a document fragment at the cursor. Replaces selection if any.
468    pub fn insert_fragment(&self, fragment: &DocumentFragment) -> Result<()> {
469        let (pos, anchor) = self.read_cursor();
470        let queued = {
471            let mut inner = self.doc.lock();
472            let dto = frontend::document_editing::InsertFragmentDto {
473                position: to_i64(pos),
474                anchor: to_i64(anchor),
475                fragment_data: fragment.raw_data().into(),
476            };
477            let result =
478                document_editing_commands::insert_fragment(&inner.ctx, Some(inner.stack_id), &dto)?;
479            let edit_pos = pos.min(anchor);
480            let removed = pos.max(anchor) - edit_pos;
481            self.finish_edit(
482                &mut inner,
483                edit_pos,
484                removed,
485                to_usize(result.new_position),
486                to_usize(result.blocks_added),
487            )
488        };
489        crate::inner::dispatch_queued_events(queued);
490        Ok(())
491    }
492
493    /// Extract the current selection as a [`DocumentFragment`].
494    pub fn selection(&self) -> DocumentFragment {
495        let (pos, anchor) = self.read_cursor();
496        if pos == anchor {
497            return DocumentFragment::new();
498        }
499        let inner = self.doc.lock();
500        let dto = frontend::document_inspection::ExtractFragmentDto {
501            position: to_i64(pos),
502            anchor: to_i64(anchor),
503        };
504        match document_inspection_commands::extract_fragment(&inner.ctx, &dto) {
505            Ok(result) => DocumentFragment::from_raw(result.fragment_data, result.plain_text),
506            Err(_) => DocumentFragment::new(),
507        }
508    }
509
510    /// Insert an image at the cursor.
511    pub fn insert_image(&self, name: &str, width: u32, height: u32) -> Result<()> {
512        let (pos, anchor) = self.read_cursor();
513        let queued = {
514            let mut inner = self.doc.lock();
515            let dto = frontend::document_editing::InsertImageDto {
516                position: to_i64(pos),
517                anchor: to_i64(anchor),
518                image_name: name.into(),
519                width: width as i64,
520                height: height as i64,
521            };
522            let result =
523                document_editing_commands::insert_image(&inner.ctx, Some(inner.stack_id), &dto)?;
524            let edit_pos = pos.min(anchor);
525            let removed = pos.max(anchor) - edit_pos;
526            self.finish_edit(&mut inner, edit_pos, removed, to_usize(result.new_position), 1)
527        };
528        crate::inner::dispatch_queued_events(queued);
529        Ok(())
530    }
531
532    /// Insert a new frame at the cursor.
533    pub fn insert_frame(&self) -> Result<()> {
534        let (pos, anchor) = self.read_cursor();
535        let queued = {
536            let mut inner = self.doc.lock();
537            let dto = frontend::document_editing::InsertFrameDto {
538                position: to_i64(pos),
539                anchor: to_i64(anchor),
540            };
541            document_editing_commands::insert_frame(&inner.ctx, Some(inner.stack_id), &dto)?;
542            // Frame insertion adds structural content; adjust cursors and emit event.
543            // The backend doesn't return a new_position, so the cursor stays put.
544            inner.modified = true;
545            inner.invalidate_text_cache();
546            inner.queue_event(DocumentEvent::ContentsChanged {
547                position: pos.min(anchor),
548                chars_removed: 0,
549                chars_added: 0,
550                blocks_affected: 1,
551            });
552            inner.check_block_count_changed();
553            self.queue_undo_redo_event(&mut inner)
554        };
555        crate::inner::dispatch_queued_events(queued);
556        Ok(())
557    }
558
559    /// Delete the character after the cursor (Delete key).
560    pub fn delete_char(&self) -> Result<()> {
561        let (pos, anchor) = self.read_cursor();
562        let (del_pos, del_anchor) = if pos != anchor {
563            (pos, anchor)
564        } else {
565            (pos, pos + 1)
566        };
567        self.do_delete(del_pos, del_anchor)
568    }
569
570    /// Delete the character before the cursor (Backspace key).
571    pub fn delete_previous_char(&self) -> Result<()> {
572        let (pos, anchor) = self.read_cursor();
573        let (del_pos, del_anchor) = if pos != anchor {
574            (pos, anchor)
575        } else if pos > 0 {
576            (pos - 1, pos)
577        } else {
578            return Ok(());
579        };
580        self.do_delete(del_pos, del_anchor)
581    }
582
583    /// Delete the selected text. Returns the deleted text. No-op if no selection.
584    pub fn remove_selected_text(&self) -> Result<String> {
585        let (pos, anchor) = self.read_cursor();
586        if pos == anchor {
587            return Ok(String::new());
588        }
589        let queued = {
590            let mut inner = self.doc.lock();
591            let dto = frontend::document_editing::DeleteTextDto {
592                position: to_i64(pos),
593                anchor: to_i64(anchor),
594            };
595            let result =
596                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
597            let edit_pos = pos.min(anchor);
598            let removed = pos.max(anchor) - edit_pos;
599            let new_pos = to_usize(result.new_position);
600            inner.adjust_cursors(edit_pos, removed, 0);
601            {
602                let mut d = self.data.lock();
603                d.position = new_pos;
604                d.anchor = new_pos;
605            }
606            inner.modified = true;
607            inner.invalidate_text_cache();
608            inner.queue_event(DocumentEvent::ContentsChanged {
609                position: edit_pos,
610                chars_removed: removed,
611                chars_added: 0,
612                blocks_affected: 1,
613            });
614            inner.check_block_count_changed();
615            // Return the deleted text alongside the queued events
616            (result.deleted_text, self.queue_undo_redo_event(&mut inner))
617        };
618        crate::inner::dispatch_queued_events(queued.1);
619        Ok(queued.0)
620    }
621
622    // ── List operations ──────────────────────────────────────
623
624    /// Turn the block(s) in the selection into a list.
625    pub fn create_list(&self, style: ListStyle) -> Result<()> {
626        let (pos, anchor) = self.read_cursor();
627        let queued = {
628            let mut inner = self.doc.lock();
629            let dto = frontend::document_editing::CreateListDto {
630                position: to_i64(pos),
631                anchor: to_i64(anchor),
632                style: style.clone(),
633            };
634            document_editing_commands::create_list(&inner.ctx, Some(inner.stack_id), &dto)?;
635            inner.modified = true;
636            inner.queue_event(DocumentEvent::ContentsChanged {
637                position: pos.min(anchor),
638                chars_removed: 0,
639                chars_added: 0,
640                blocks_affected: 1,
641            });
642            self.queue_undo_redo_event(&mut inner)
643        };
644        crate::inner::dispatch_queued_events(queued);
645        Ok(())
646    }
647
648    /// Insert a new list item at the cursor position.
649    pub fn insert_list(&self, style: ListStyle) -> Result<()> {
650        let (pos, anchor) = self.read_cursor();
651        let queued = {
652            let mut inner = self.doc.lock();
653            let dto = frontend::document_editing::InsertListDto {
654                position: to_i64(pos),
655                anchor: to_i64(anchor),
656                style: style.clone(),
657            };
658            let result =
659                document_editing_commands::insert_list(&inner.ctx, Some(inner.stack_id), &dto)?;
660            let edit_pos = pos.min(anchor);
661            let removed = pos.max(anchor) - edit_pos;
662            self.finish_edit(&mut inner, edit_pos, removed, to_usize(result.new_position), 1)
663        };
664        crate::inner::dispatch_queued_events(queued);
665        Ok(())
666    }
667
668    // ── Format queries ───────────────────────────────────────
669
670    /// Get the character format at the cursor position.
671    pub fn char_format(&self) -> Result<TextFormat> {
672        let pos = self.position();
673        let inner = self.doc.lock();
674        let dto = frontend::document_inspection::GetTextAtPositionDto {
675            position: to_i64(pos),
676            length: 1,
677        };
678        let text_info = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
679        let element_id = text_info.element_id as u64;
680        let element = inline_element_commands::get_inline_element(&inner.ctx, &element_id)?
681            .ok_or_else(|| anyhow::anyhow!("element not found at position"))?;
682        Ok(TextFormat::from(&element))
683    }
684
685    /// Get the block format of the block containing the cursor.
686    pub fn block_format(&self) -> Result<BlockFormat> {
687        let pos = self.position();
688        let inner = self.doc.lock();
689        let dto = frontend::document_inspection::GetBlockAtPositionDto {
690            position: to_i64(pos),
691        };
692        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
693        let block_id = block_info.block_id as u64;
694        let block = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
695            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
696        Ok(BlockFormat::from(&block))
697    }
698
699    // ── Format application ───────────────────────────────────
700
701    /// Set the character format for the selection.
702    pub fn set_char_format(&self, format: &TextFormat) -> Result<()> {
703        let (pos, anchor) = self.read_cursor();
704        let queued = {
705            let mut inner = self.doc.lock();
706            let dto = format.to_set_dto(pos, anchor);
707            document_formatting_commands::set_text_format(&inner.ctx, Some(inner.stack_id), &dto)?;
708            let start = pos.min(anchor);
709            let length = pos.max(anchor) - start;
710            inner.modified = true;
711            inner.queue_event(DocumentEvent::FormatChanged {
712                position: start,
713                length,
714            });
715            self.queue_undo_redo_event(&mut inner)
716        };
717        crate::inner::dispatch_queued_events(queued);
718        Ok(())
719    }
720
721    /// Merge a character format into the selection.
722    pub fn merge_char_format(&self, format: &TextFormat) -> Result<()> {
723        let (pos, anchor) = self.read_cursor();
724        let queued = {
725            let mut inner = self.doc.lock();
726            let dto = format.to_merge_dto(pos, anchor);
727            document_formatting_commands::merge_text_format(
728                &inner.ctx,
729                Some(inner.stack_id),
730                &dto,
731            )?;
732            let start = pos.min(anchor);
733            let length = pos.max(anchor) - start;
734            inner.modified = true;
735            inner.queue_event(DocumentEvent::FormatChanged {
736                position: start,
737                length,
738            });
739            self.queue_undo_redo_event(&mut inner)
740        };
741        crate::inner::dispatch_queued_events(queued);
742        Ok(())
743    }
744
745    /// Set the block format for the current block (or all blocks in selection).
746    pub fn set_block_format(&self, format: &BlockFormat) -> Result<()> {
747        let (pos, anchor) = self.read_cursor();
748        let queued = {
749            let mut inner = self.doc.lock();
750            let dto = format.to_set_dto(pos, anchor);
751            document_formatting_commands::set_block_format(
752                &inner.ctx,
753                Some(inner.stack_id),
754                &dto,
755            )?;
756            let start = pos.min(anchor);
757            let length = pos.max(anchor) - start;
758            inner.modified = true;
759            inner.queue_event(DocumentEvent::FormatChanged {
760                position: start,
761                length,
762            });
763            self.queue_undo_redo_event(&mut inner)
764        };
765        crate::inner::dispatch_queued_events(queued);
766        Ok(())
767    }
768
769    /// Set the frame format.
770    pub fn set_frame_format(&self, frame_id: usize, format: &FrameFormat) -> Result<()> {
771        let (pos, anchor) = self.read_cursor();
772        let queued = {
773            let mut inner = self.doc.lock();
774            let dto = format.to_set_dto(pos, anchor, frame_id);
775            document_formatting_commands::set_frame_format(
776                &inner.ctx,
777                Some(inner.stack_id),
778                &dto,
779            )?;
780            let start = pos.min(anchor);
781            let length = pos.max(anchor) - start;
782            inner.modified = true;
783            inner.queue_event(DocumentEvent::FormatChanged {
784                position: start,
785                length,
786            });
787            self.queue_undo_redo_event(&mut inner)
788        };
789        crate::inner::dispatch_queued_events(queued);
790        Ok(())
791    }
792
793    // ── Edit blocks (composite undo) ─────────────────────────
794
795    /// Begin a group of operations that will be undone as a single unit.
796    pub fn begin_edit_block(&self) {
797        let inner = self.doc.lock();
798        undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
799    }
800
801    /// End the current edit block.
802    pub fn end_edit_block(&self) {
803        let inner = self.doc.lock();
804        undo_redo_commands::end_composite(&inner.ctx);
805    }
806
807    /// Alias for [`begin_edit_block`](Self::begin_edit_block).
808    ///
809    /// Semantically indicates that the new composite should be merged with
810    /// the previous one (e.g., consecutive keystrokes grouped into a single
811    /// undo unit). The current backend treats this identically to
812    /// `begin_edit_block`; future versions may implement automatic merging.
813    pub fn join_previous_edit_block(&self) {
814        self.begin_edit_block();
815    }
816
817    // ── Private helpers ─────────────────────────────────────
818
819    /// Queue an `UndoRedoChanged` event and return all queued events for dispatch.
820    fn queue_undo_redo_event(
821        &self,
822        inner: &mut TextDocumentInner,
823    ) -> Vec<(DocumentEvent, Vec<Arc<dyn Fn(DocumentEvent) + Send + Sync>>)> {
824        let can_undo =
825            undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
826        let can_redo =
827            undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
828        inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
829        inner.take_queued_events()
830    }
831
832    fn do_delete(&self, pos: usize, anchor: usize) -> Result<()> {
833        let queued = {
834            let mut inner = self.doc.lock();
835            let dto = frontend::document_editing::DeleteTextDto {
836                position: to_i64(pos),
837                anchor: to_i64(anchor),
838            };
839            let result =
840                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
841            let edit_pos = pos.min(anchor);
842            let removed = pos.max(anchor) - edit_pos;
843            let new_pos = to_usize(result.new_position);
844            inner.adjust_cursors(edit_pos, removed, 0);
845            {
846                let mut d = self.data.lock();
847                d.position = new_pos;
848                d.anchor = new_pos;
849            }
850            inner.modified = true;
851            inner.invalidate_text_cache();
852            inner.queue_event(DocumentEvent::ContentsChanged {
853                position: edit_pos,
854                chars_removed: removed,
855                chars_added: 0,
856                blocks_affected: 1,
857            });
858            inner.check_block_count_changed();
859            self.queue_undo_redo_event(&mut inner)
860        };
861        crate::inner::dispatch_queued_events(queued);
862        Ok(())
863    }
864
865    /// Resolve a MoveOperation to a concrete position.
866    fn resolve_move(&self, op: MoveOperation, n: usize) -> usize {
867        let pos = self.position();
868        match op {
869            MoveOperation::NoMove => pos,
870            MoveOperation::Start => 0,
871            MoveOperation::End => {
872                let inner = self.doc.lock();
873                document_inspection_commands::get_document_stats(&inner.ctx)
874                    .map(|s| to_usize(s.character_count))
875                    .unwrap_or(pos)
876            }
877            MoveOperation::NextCharacter | MoveOperation::Right => pos + n,
878            MoveOperation::PreviousCharacter | MoveOperation::Left => pos.saturating_sub(n),
879            MoveOperation::StartOfBlock | MoveOperation::StartOfLine => {
880                let inner = self.doc.lock();
881                let dto = frontend::document_inspection::GetBlockAtPositionDto {
882                    position: to_i64(pos),
883                };
884                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
885                    .map(|info| to_usize(info.block_start))
886                    .unwrap_or(pos)
887            }
888            MoveOperation::EndOfBlock | MoveOperation::EndOfLine => {
889                let inner = self.doc.lock();
890                let dto = frontend::document_inspection::GetBlockAtPositionDto {
891                    position: to_i64(pos),
892                };
893                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
894                    .map(|info| to_usize(info.block_start) + to_usize(info.block_length))
895                    .unwrap_or(pos)
896            }
897            MoveOperation::NextBlock => {
898                let inner = self.doc.lock();
899                let dto = frontend::document_inspection::GetBlockAtPositionDto {
900                    position: to_i64(pos),
901                };
902                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
903                    .map(|info| {
904                        // Move past current block + 1 (block separator)
905                        to_usize(info.block_start) + to_usize(info.block_length) + 1
906                    })
907                    .unwrap_or(pos)
908            }
909            MoveOperation::PreviousBlock => {
910                let inner = self.doc.lock();
911                let dto = frontend::document_inspection::GetBlockAtPositionDto {
912                    position: to_i64(pos),
913                };
914                let block_start =
915                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
916                        .map(|info| to_usize(info.block_start))
917                        .unwrap_or(pos);
918                if block_start >= 2 {
919                    // Skip past the block separator (which maps to the current block)
920                    let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
921                        position: to_i64(block_start - 2),
922                    };
923                    document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto)
924                        .map(|info| to_usize(info.block_start))
925                        .unwrap_or(0)
926                } else {
927                    0
928                }
929            }
930            MoveOperation::NextWord | MoveOperation::EndOfWord | MoveOperation::WordRight => {
931                let (_, end) = self.find_word_boundaries(pos);
932                // Move past the word end to the next word
933                if end == pos {
934                    // Already at a boundary, skip whitespace
935                    let inner = self.doc.lock();
936                    let stats = document_inspection_commands::get_document_stats(&inner.ctx)
937                        .map(|s| to_usize(s.character_count))
938                        .unwrap_or(0);
939                    let scan_len = (stats - pos).min(64);
940                    if scan_len == 0 {
941                        return pos;
942                    }
943                    let dto = frontend::document_inspection::GetTextAtPositionDto {
944                        position: to_i64(pos),
945                        length: to_i64(scan_len),
946                    };
947                    if let Ok(r) =
948                        document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
949                    {
950                        for (i, ch) in r.text.chars().enumerate() {
951                            if ch.is_alphanumeric() || ch == '_' {
952                                // Found start of next word, find its end
953                                let word_pos = pos + i;
954                                drop(inner);
955                                let (_, word_end) = self.find_word_boundaries(word_pos);
956                                return word_end;
957                            }
958                        }
959                    }
960                    pos + scan_len
961                } else {
962                    end
963                }
964            }
965            MoveOperation::PreviousWord | MoveOperation::StartOfWord | MoveOperation::WordLeft => {
966                let (start, _) = self.find_word_boundaries(pos);
967                if start < pos {
968                    start
969                } else if pos > 0 {
970                    // Cursor is at a word start or on whitespace — scan backwards
971                    // to find the start of the previous word.
972                    let mut search = pos - 1;
973                    loop {
974                        let (ws, we) = self.find_word_boundaries(search);
975                        if ws < we {
976                            // Found a word; return its start
977                            break ws;
978                        }
979                        // Still on whitespace/non-word; keep scanning
980                        if search == 0 {
981                            break 0;
982                        }
983                        search -= 1;
984                    }
985                } else {
986                    0
987                }
988            }
989            MoveOperation::Up | MoveOperation::Down => {
990                // Up/Down are visual operations that depend on line wrapping.
991                // Without layout info, treat as PreviousBlock/NextBlock.
992                if matches!(op, MoveOperation::Up) {
993                    self.resolve_move(MoveOperation::PreviousBlock, 1)
994                } else {
995                    self.resolve_move(MoveOperation::NextBlock, 1)
996                }
997            }
998        }
999    }
1000
1001    /// Find the word boundaries around `pos`. Returns (start, end).
1002    /// Uses Unicode word segmentation for correct handling of non-ASCII text.
1003    ///
1004    /// Single-pass: tracks the last word seen to avoid a second iteration
1005    /// when the cursor is at the end of the last word (ISSUE-18).
1006    fn find_word_boundaries(&self, pos: usize) -> (usize, usize) {
1007        let inner = self.doc.lock();
1008        // Get block info so we can fetch the full block text
1009        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
1010            position: to_i64(pos),
1011        };
1012        let block_info =
1013            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
1014                Ok(info) => info,
1015                Err(_) => return (pos, pos),
1016            };
1017
1018        let block_start = to_usize(block_info.block_start);
1019        let block_length = to_usize(block_info.block_length);
1020        if block_length == 0 {
1021            return (pos, pos);
1022        }
1023
1024        let dto = frontend::document_inspection::GetTextAtPositionDto {
1025            position: to_i64(block_start),
1026            length: to_i64(block_length),
1027        };
1028        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &dto) {
1029            Ok(r) => r.text,
1030            Err(_) => return (pos, pos),
1031        };
1032
1033        // cursor_offset is the char offset within the block text
1034        let cursor_offset = pos.saturating_sub(block_start);
1035
1036        // Single pass: track the last word seen for end-of-last-word check
1037        let mut last_char_start = 0;
1038        let mut last_char_end = 0;
1039
1040        for (word_byte_start, word) in text.unicode_word_indices() {
1041            // Convert byte offset to char offset
1042            let word_char_start = text[..word_byte_start].chars().count();
1043            let word_char_len = word.chars().count();
1044            let word_char_end = word_char_start + word_char_len;
1045
1046            last_char_start = word_char_start;
1047            last_char_end = word_char_end;
1048
1049            if cursor_offset >= word_char_start && cursor_offset < word_char_end {
1050                return (block_start + word_char_start, block_start + word_char_end);
1051            }
1052        }
1053
1054        // Check if cursor is exactly at the end of the last word
1055        if cursor_offset == last_char_end && last_char_start < last_char_end {
1056            return (block_start + last_char_start, block_start + last_char_end);
1057        }
1058
1059        (pos, pos)
1060    }
1061}