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