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 crate::ListStyle;
10use frontend::commands::{
11    document_editing_commands, document_formatting_commands, document_inspection_commands,
12    inline_element_commands, undo_redo_commands,
13};
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, QueuedEvents, 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    ) -> QueuedEvents {
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 = 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(
384                &mut inner,
385                edit_pos,
386                removed,
387                to_usize(result.new_position),
388                1,
389            )
390        };
391        crate::inner::dispatch_queued_events(queued);
392        Ok(())
393    }
394
395    /// Insert a block break (new paragraph). Replaces selection if any.
396    pub fn insert_block(&self) -> Result<()> {
397        let (pos, anchor) = self.read_cursor();
398        let queued = {
399            let mut inner = self.doc.lock();
400            let dto = frontend::document_editing::InsertBlockDto {
401                position: to_i64(pos),
402                anchor: to_i64(anchor),
403            };
404            let result =
405                document_editing_commands::insert_block(&inner.ctx, Some(inner.stack_id), &dto)?;
406            let edit_pos = pos.min(anchor);
407            let removed = pos.max(anchor) - edit_pos;
408            self.finish_edit(
409                &mut inner,
410                edit_pos,
411                removed,
412                to_usize(result.new_position),
413                2,
414            )
415        };
416        crate::inner::dispatch_queued_events(queued);
417        Ok(())
418    }
419
420    /// Insert an HTML fragment at the cursor position. Replaces selection if any.
421    pub fn insert_html(&self, html: &str) -> Result<()> {
422        let (pos, anchor) = self.read_cursor();
423        let queued = {
424            let mut inner = self.doc.lock();
425            let dto = frontend::document_editing::InsertHtmlAtPositionDto {
426                position: to_i64(pos),
427                anchor: to_i64(anchor),
428                html: html.into(),
429            };
430            let result = document_editing_commands::insert_html_at_position(
431                &inner.ctx,
432                Some(inner.stack_id),
433                &dto,
434            )?;
435            let edit_pos = pos.min(anchor);
436            let removed = pos.max(anchor) - edit_pos;
437            self.finish_edit(
438                &mut inner,
439                edit_pos,
440                removed,
441                to_usize(result.new_position),
442                to_usize(result.blocks_added),
443            )
444        };
445        crate::inner::dispatch_queued_events(queued);
446        Ok(())
447    }
448
449    /// Insert a Markdown fragment at the cursor position. Replaces selection if any.
450    pub fn insert_markdown(&self, markdown: &str) -> Result<()> {
451        let (pos, anchor) = self.read_cursor();
452        let queued = {
453            let mut inner = self.doc.lock();
454            let dto = frontend::document_editing::InsertMarkdownAtPositionDto {
455                position: to_i64(pos),
456                anchor: to_i64(anchor),
457                markdown: markdown.into(),
458            };
459            let result = document_editing_commands::insert_markdown_at_position(
460                &inner.ctx,
461                Some(inner.stack_id),
462                &dto,
463            )?;
464            let edit_pos = pos.min(anchor);
465            let removed = pos.max(anchor) - edit_pos;
466            self.finish_edit(
467                &mut inner,
468                edit_pos,
469                removed,
470                to_usize(result.new_position),
471                to_usize(result.blocks_added),
472            )
473        };
474        crate::inner::dispatch_queued_events(queued);
475        Ok(())
476    }
477
478    /// Insert a document fragment at the cursor. Replaces selection if any.
479    pub fn insert_fragment(&self, fragment: &DocumentFragment) -> Result<()> {
480        let (pos, anchor) = self.read_cursor();
481        let queued = {
482            let mut inner = self.doc.lock();
483            let dto = frontend::document_editing::InsertFragmentDto {
484                position: to_i64(pos),
485                anchor: to_i64(anchor),
486                fragment_data: fragment.raw_data().into(),
487            };
488            let result =
489                document_editing_commands::insert_fragment(&inner.ctx, Some(inner.stack_id), &dto)?;
490            let edit_pos = pos.min(anchor);
491            let removed = pos.max(anchor) - edit_pos;
492            self.finish_edit(
493                &mut inner,
494                edit_pos,
495                removed,
496                to_usize(result.new_position),
497                to_usize(result.blocks_added),
498            )
499        };
500        crate::inner::dispatch_queued_events(queued);
501        Ok(())
502    }
503
504    /// Extract the current selection as a [`DocumentFragment`].
505    pub fn selection(&self) -> DocumentFragment {
506        let (pos, anchor) = self.read_cursor();
507        if pos == anchor {
508            return DocumentFragment::new();
509        }
510        let inner = self.doc.lock();
511        let dto = frontend::document_inspection::ExtractFragmentDto {
512            position: to_i64(pos),
513            anchor: to_i64(anchor),
514        };
515        match document_inspection_commands::extract_fragment(&inner.ctx, &dto) {
516            Ok(result) => DocumentFragment::from_raw(result.fragment_data, result.plain_text),
517            Err(_) => DocumentFragment::new(),
518        }
519    }
520
521    /// Insert an image at the cursor.
522    pub fn insert_image(&self, name: &str, width: u32, height: u32) -> Result<()> {
523        let (pos, anchor) = self.read_cursor();
524        let queued = {
525            let mut inner = self.doc.lock();
526            let dto = frontend::document_editing::InsertImageDto {
527                position: to_i64(pos),
528                anchor: to_i64(anchor),
529                image_name: name.into(),
530                width: width as i64,
531                height: height as i64,
532            };
533            let result =
534                document_editing_commands::insert_image(&inner.ctx, Some(inner.stack_id), &dto)?;
535            let edit_pos = pos.min(anchor);
536            let removed = pos.max(anchor) - edit_pos;
537            self.finish_edit(
538                &mut inner,
539                edit_pos,
540                removed,
541                to_usize(result.new_position),
542                1,
543            )
544        };
545        crate::inner::dispatch_queued_events(queued);
546        Ok(())
547    }
548
549    /// Insert a new frame at the cursor.
550    pub fn insert_frame(&self) -> Result<()> {
551        let (pos, anchor) = self.read_cursor();
552        let queued = {
553            let mut inner = self.doc.lock();
554            let dto = frontend::document_editing::InsertFrameDto {
555                position: to_i64(pos),
556                anchor: to_i64(anchor),
557            };
558            document_editing_commands::insert_frame(&inner.ctx, Some(inner.stack_id), &dto)?;
559            // Frame insertion adds structural content; adjust cursors and emit event.
560            // The backend doesn't return a new_position, so the cursor stays put.
561            inner.modified = true;
562            inner.invalidate_text_cache();
563            inner.queue_event(DocumentEvent::ContentsChanged {
564                position: pos.min(anchor),
565                chars_removed: 0,
566                chars_added: 0,
567                blocks_affected: 1,
568            });
569            inner.check_block_count_changed();
570            self.queue_undo_redo_event(&mut inner)
571        };
572        crate::inner::dispatch_queued_events(queued);
573        Ok(())
574    }
575
576    /// Delete the character after the cursor (Delete key).
577    pub fn delete_char(&self) -> Result<()> {
578        let (pos, anchor) = self.read_cursor();
579        let (del_pos, del_anchor) = if pos != anchor {
580            (pos, anchor)
581        } else {
582            (pos, pos + 1)
583        };
584        self.do_delete(del_pos, del_anchor)
585    }
586
587    /// Delete the character before the cursor (Backspace key).
588    pub fn delete_previous_char(&self) -> Result<()> {
589        let (pos, anchor) = self.read_cursor();
590        let (del_pos, del_anchor) = if pos != anchor {
591            (pos, anchor)
592        } else if pos > 0 {
593            (pos - 1, pos)
594        } else {
595            return Ok(());
596        };
597        self.do_delete(del_pos, del_anchor)
598    }
599
600    /// Delete the selected text. Returns the deleted text. No-op if no selection.
601    pub fn remove_selected_text(&self) -> Result<String> {
602        let (pos, anchor) = self.read_cursor();
603        if pos == anchor {
604            return Ok(String::new());
605        }
606        let queued = {
607            let mut inner = self.doc.lock();
608            let dto = frontend::document_editing::DeleteTextDto {
609                position: to_i64(pos),
610                anchor: to_i64(anchor),
611            };
612            let result =
613                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
614            let edit_pos = pos.min(anchor);
615            let removed = pos.max(anchor) - edit_pos;
616            let new_pos = to_usize(result.new_position);
617            inner.adjust_cursors(edit_pos, removed, 0);
618            {
619                let mut d = self.data.lock();
620                d.position = new_pos;
621                d.anchor = new_pos;
622            }
623            inner.modified = true;
624            inner.invalidate_text_cache();
625            inner.queue_event(DocumentEvent::ContentsChanged {
626                position: edit_pos,
627                chars_removed: removed,
628                chars_added: 0,
629                blocks_affected: 1,
630            });
631            inner.check_block_count_changed();
632            // Return the deleted text alongside the queued events
633            (result.deleted_text, self.queue_undo_redo_event(&mut inner))
634        };
635        crate::inner::dispatch_queued_events(queued.1);
636        Ok(queued.0)
637    }
638
639    // ── List operations ──────────────────────────────────────
640
641    /// Turn the block(s) in the selection into a list.
642    pub fn create_list(&self, style: ListStyle) -> Result<()> {
643        let (pos, anchor) = self.read_cursor();
644        let queued = {
645            let mut inner = self.doc.lock();
646            let dto = frontend::document_editing::CreateListDto {
647                position: to_i64(pos),
648                anchor: to_i64(anchor),
649                style: style.clone(),
650            };
651            document_editing_commands::create_list(&inner.ctx, Some(inner.stack_id), &dto)?;
652            inner.modified = true;
653            inner.queue_event(DocumentEvent::ContentsChanged {
654                position: pos.min(anchor),
655                chars_removed: 0,
656                chars_added: 0,
657                blocks_affected: 1,
658            });
659            self.queue_undo_redo_event(&mut inner)
660        };
661        crate::inner::dispatch_queued_events(queued);
662        Ok(())
663    }
664
665    /// Insert a new list item at the cursor position.
666    pub fn insert_list(&self, style: ListStyle) -> Result<()> {
667        let (pos, anchor) = self.read_cursor();
668        let queued = {
669            let mut inner = self.doc.lock();
670            let dto = frontend::document_editing::InsertListDto {
671                position: to_i64(pos),
672                anchor: to_i64(anchor),
673                style: style.clone(),
674            };
675            let result =
676                document_editing_commands::insert_list(&inner.ctx, Some(inner.stack_id), &dto)?;
677            let edit_pos = pos.min(anchor);
678            let removed = pos.max(anchor) - edit_pos;
679            self.finish_edit(
680                &mut inner,
681                edit_pos,
682                removed,
683                to_usize(result.new_position),
684                1,
685            )
686        };
687        crate::inner::dispatch_queued_events(queued);
688        Ok(())
689    }
690
691    // ── Format queries ───────────────────────────────────────
692
693    /// Get the character format at the cursor position.
694    pub fn char_format(&self) -> Result<TextFormat> {
695        let pos = self.position();
696        let inner = self.doc.lock();
697        let dto = frontend::document_inspection::GetTextAtPositionDto {
698            position: to_i64(pos),
699            length: 1,
700        };
701        let text_info = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
702        let element_id = text_info.element_id as u64;
703        let element = inline_element_commands::get_inline_element(&inner.ctx, &element_id)?
704            .ok_or_else(|| anyhow::anyhow!("element not found at position"))?;
705        Ok(TextFormat::from(&element))
706    }
707
708    /// Get the block format of the block containing the cursor.
709    pub fn block_format(&self) -> Result<BlockFormat> {
710        let pos = self.position();
711        let inner = self.doc.lock();
712        let dto = frontend::document_inspection::GetBlockAtPositionDto {
713            position: to_i64(pos),
714        };
715        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
716        let block_id = block_info.block_id as u64;
717        let block = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
718            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
719        Ok(BlockFormat::from(&block))
720    }
721
722    // ── Format application ───────────────────────────────────
723
724    /// Set the character format for the selection.
725    pub fn set_char_format(&self, format: &TextFormat) -> Result<()> {
726        let (pos, anchor) = self.read_cursor();
727        let queued = {
728            let mut inner = self.doc.lock();
729            let dto = format.to_set_dto(pos, anchor);
730            document_formatting_commands::set_text_format(&inner.ctx, Some(inner.stack_id), &dto)?;
731            let start = pos.min(anchor);
732            let length = pos.max(anchor) - start;
733            inner.modified = true;
734            inner.queue_event(DocumentEvent::FormatChanged {
735                position: start,
736                length,
737            });
738            self.queue_undo_redo_event(&mut inner)
739        };
740        crate::inner::dispatch_queued_events(queued);
741        Ok(())
742    }
743
744    /// Merge a character format into the selection.
745    pub fn merge_char_format(&self, format: &TextFormat) -> Result<()> {
746        let (pos, anchor) = self.read_cursor();
747        let queued = {
748            let mut inner = self.doc.lock();
749            let dto = format.to_merge_dto(pos, anchor);
750            document_formatting_commands::merge_text_format(
751                &inner.ctx,
752                Some(inner.stack_id),
753                &dto,
754            )?;
755            let start = pos.min(anchor);
756            let length = pos.max(anchor) - start;
757            inner.modified = true;
758            inner.queue_event(DocumentEvent::FormatChanged {
759                position: start,
760                length,
761            });
762            self.queue_undo_redo_event(&mut inner)
763        };
764        crate::inner::dispatch_queued_events(queued);
765        Ok(())
766    }
767
768    /// Set the block format for the current block (or all blocks in selection).
769    pub fn set_block_format(&self, format: &BlockFormat) -> Result<()> {
770        let (pos, anchor) = self.read_cursor();
771        let queued = {
772            let mut inner = self.doc.lock();
773            let dto = format.to_set_dto(pos, anchor);
774            document_formatting_commands::set_block_format(&inner.ctx, Some(inner.stack_id), &dto)?;
775            let start = pos.min(anchor);
776            let length = pos.max(anchor) - start;
777            inner.modified = true;
778            inner.queue_event(DocumentEvent::FormatChanged {
779                position: start,
780                length,
781            });
782            self.queue_undo_redo_event(&mut inner)
783        };
784        crate::inner::dispatch_queued_events(queued);
785        Ok(())
786    }
787
788    /// Set the frame format.
789    pub fn set_frame_format(&self, frame_id: usize, format: &FrameFormat) -> Result<()> {
790        let (pos, anchor) = self.read_cursor();
791        let queued = {
792            let mut inner = self.doc.lock();
793            let dto = format.to_set_dto(pos, anchor, frame_id);
794            document_formatting_commands::set_frame_format(&inner.ctx, Some(inner.stack_id), &dto)?;
795            let start = pos.min(anchor);
796            let length = pos.max(anchor) - start;
797            inner.modified = true;
798            inner.queue_event(DocumentEvent::FormatChanged {
799                position: start,
800                length,
801            });
802            self.queue_undo_redo_event(&mut inner)
803        };
804        crate::inner::dispatch_queued_events(queued);
805        Ok(())
806    }
807
808    // ── Edit blocks (composite undo) ─────────────────────────
809
810    /// Begin a group of operations that will be undone as a single unit.
811    pub fn begin_edit_block(&self) {
812        let inner = self.doc.lock();
813        undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
814    }
815
816    /// End the current edit block.
817    pub fn end_edit_block(&self) {
818        let inner = self.doc.lock();
819        undo_redo_commands::end_composite(&inner.ctx);
820    }
821
822    /// Alias for [`begin_edit_block`](Self::begin_edit_block).
823    ///
824    /// Semantically indicates that the new composite should be merged with
825    /// the previous one (e.g., consecutive keystrokes grouped into a single
826    /// undo unit). The current backend treats this identically to
827    /// `begin_edit_block`; future versions may implement automatic merging.
828    pub fn join_previous_edit_block(&self) {
829        self.begin_edit_block();
830    }
831
832    // ── Private helpers ─────────────────────────────────────
833
834    /// Queue an `UndoRedoChanged` event and return all queued events for dispatch.
835    fn queue_undo_redo_event(&self, inner: &mut TextDocumentInner) -> QueuedEvents {
836        let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
837        let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
838        inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
839        inner.take_queued_events()
840    }
841
842    fn do_delete(&self, pos: usize, anchor: usize) -> Result<()> {
843        let queued = {
844            let mut inner = self.doc.lock();
845            let dto = frontend::document_editing::DeleteTextDto {
846                position: to_i64(pos),
847                anchor: to_i64(anchor),
848            };
849            let result =
850                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
851            let edit_pos = pos.min(anchor);
852            let removed = pos.max(anchor) - edit_pos;
853            let new_pos = to_usize(result.new_position);
854            inner.adjust_cursors(edit_pos, removed, 0);
855            {
856                let mut d = self.data.lock();
857                d.position = new_pos;
858                d.anchor = new_pos;
859            }
860            inner.modified = true;
861            inner.invalidate_text_cache();
862            inner.queue_event(DocumentEvent::ContentsChanged {
863                position: edit_pos,
864                chars_removed: removed,
865                chars_added: 0,
866                blocks_affected: 1,
867            });
868            inner.check_block_count_changed();
869            self.queue_undo_redo_event(&mut inner)
870        };
871        crate::inner::dispatch_queued_events(queued);
872        Ok(())
873    }
874
875    /// Resolve a MoveOperation to a concrete position.
876    fn resolve_move(&self, op: MoveOperation, n: usize) -> usize {
877        let pos = self.position();
878        match op {
879            MoveOperation::NoMove => pos,
880            MoveOperation::Start => 0,
881            MoveOperation::End => {
882                let inner = self.doc.lock();
883                document_inspection_commands::get_document_stats(&inner.ctx)
884                    .map(|s| to_usize(s.character_count))
885                    .unwrap_or(pos)
886            }
887            MoveOperation::NextCharacter | MoveOperation::Right => pos + n,
888            MoveOperation::PreviousCharacter | MoveOperation::Left => pos.saturating_sub(n),
889            MoveOperation::StartOfBlock | MoveOperation::StartOfLine => {
890                let inner = self.doc.lock();
891                let dto = frontend::document_inspection::GetBlockAtPositionDto {
892                    position: to_i64(pos),
893                };
894                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
895                    .map(|info| to_usize(info.block_start))
896                    .unwrap_or(pos)
897            }
898            MoveOperation::EndOfBlock | MoveOperation::EndOfLine => {
899                let inner = self.doc.lock();
900                let dto = frontend::document_inspection::GetBlockAtPositionDto {
901                    position: to_i64(pos),
902                };
903                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
904                    .map(|info| to_usize(info.block_start) + to_usize(info.block_length))
905                    .unwrap_or(pos)
906            }
907            MoveOperation::NextBlock => {
908                let inner = self.doc.lock();
909                let dto = frontend::document_inspection::GetBlockAtPositionDto {
910                    position: to_i64(pos),
911                };
912                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
913                    .map(|info| {
914                        // Move past current block + 1 (block separator)
915                        to_usize(info.block_start) + to_usize(info.block_length) + 1
916                    })
917                    .unwrap_or(pos)
918            }
919            MoveOperation::PreviousBlock => {
920                let inner = self.doc.lock();
921                let dto = frontend::document_inspection::GetBlockAtPositionDto {
922                    position: to_i64(pos),
923                };
924                let block_start =
925                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
926                        .map(|info| to_usize(info.block_start))
927                        .unwrap_or(pos);
928                if block_start >= 2 {
929                    // Skip past the block separator (which maps to the current block)
930                    let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
931                        position: to_i64(block_start - 2),
932                    };
933                    document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto)
934                        .map(|info| to_usize(info.block_start))
935                        .unwrap_or(0)
936                } else {
937                    0
938                }
939            }
940            MoveOperation::NextWord | MoveOperation::EndOfWord | MoveOperation::WordRight => {
941                let (_, end) = self.find_word_boundaries(pos);
942                // Move past the word end to the next word
943                if end == pos {
944                    // Already at a boundary, skip whitespace
945                    let inner = self.doc.lock();
946                    let stats = document_inspection_commands::get_document_stats(&inner.ctx)
947                        .map(|s| to_usize(s.character_count))
948                        .unwrap_or(0);
949                    let scan_len = (stats - pos).min(64);
950                    if scan_len == 0 {
951                        return pos;
952                    }
953                    let dto = frontend::document_inspection::GetTextAtPositionDto {
954                        position: to_i64(pos),
955                        length: to_i64(scan_len),
956                    };
957                    if let Ok(r) =
958                        document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
959                    {
960                        for (i, ch) in r.text.chars().enumerate() {
961                            if ch.is_alphanumeric() || ch == '_' {
962                                // Found start of next word, find its end
963                                let word_pos = pos + i;
964                                drop(inner);
965                                let (_, word_end) = self.find_word_boundaries(word_pos);
966                                return word_end;
967                            }
968                        }
969                    }
970                    pos + scan_len
971                } else {
972                    end
973                }
974            }
975            MoveOperation::PreviousWord | MoveOperation::StartOfWord | MoveOperation::WordLeft => {
976                let (start, _) = self.find_word_boundaries(pos);
977                if start < pos {
978                    start
979                } else if pos > 0 {
980                    // Cursor is at a word start or on whitespace — scan backwards
981                    // to find the start of the previous word.
982                    let mut search = pos - 1;
983                    loop {
984                        let (ws, we) = self.find_word_boundaries(search);
985                        if ws < we {
986                            // Found a word; return its start
987                            break ws;
988                        }
989                        // Still on whitespace/non-word; keep scanning
990                        if search == 0 {
991                            break 0;
992                        }
993                        search -= 1;
994                    }
995                } else {
996                    0
997                }
998            }
999            MoveOperation::Up | MoveOperation::Down => {
1000                // Up/Down are visual operations that depend on line wrapping.
1001                // Without layout info, treat as PreviousBlock/NextBlock.
1002                if matches!(op, MoveOperation::Up) {
1003                    self.resolve_move(MoveOperation::PreviousBlock, 1)
1004                } else {
1005                    self.resolve_move(MoveOperation::NextBlock, 1)
1006                }
1007            }
1008        }
1009    }
1010
1011    /// Find the word boundaries around `pos`. Returns (start, end).
1012    /// Uses Unicode word segmentation for correct handling of non-ASCII text.
1013    ///
1014    /// Single-pass: tracks the last word seen to avoid a second iteration
1015    /// when the cursor is at the end of the last word (ISSUE-18).
1016    fn find_word_boundaries(&self, pos: usize) -> (usize, usize) {
1017        let inner = self.doc.lock();
1018        // Get block info so we can fetch the full block text
1019        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
1020            position: to_i64(pos),
1021        };
1022        let block_info =
1023            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
1024                Ok(info) => info,
1025                Err(_) => return (pos, pos),
1026            };
1027
1028        let block_start = to_usize(block_info.block_start);
1029        let block_length = to_usize(block_info.block_length);
1030        if block_length == 0 {
1031            return (pos, pos);
1032        }
1033
1034        let dto = frontend::document_inspection::GetTextAtPositionDto {
1035            position: to_i64(block_start),
1036            length: to_i64(block_length),
1037        };
1038        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &dto) {
1039            Ok(r) => r.text,
1040            Err(_) => return (pos, pos),
1041        };
1042
1043        // cursor_offset is the char offset within the block text
1044        let cursor_offset = pos.saturating_sub(block_start);
1045
1046        // Single pass: track the last word seen for end-of-last-word check
1047        let mut last_char_start = 0;
1048        let mut last_char_end = 0;
1049
1050        for (word_byte_start, word) in text.unicode_word_indices() {
1051            // Convert byte offset to char offset
1052            let word_char_start = text[..word_byte_start].chars().count();
1053            let word_char_len = word.chars().count();
1054            let word_char_end = word_char_start + word_char_len;
1055
1056            last_char_start = word_char_start;
1057            last_char_end = word_char_end;
1058
1059            if cursor_offset >= word_char_start && cursor_offset < word_char_end {
1060                return (block_start + word_char_start, block_start + word_char_end);
1061            }
1062        }
1063
1064        // Check if cursor is exactly at the end of the last word
1065        if cursor_offset == last_char_end && last_char_start < last_char_end {
1066            return (block_start + last_char_start, block_start + last_char_end);
1067        }
1068
1069        (pos, pos)
1070    }
1071}