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 crate::{DocumentError, Result};
8
9use crate::ListStyle;
10use frontend::commands::{
11    document_editing_commands, document_formatting_commands, document_inspection_commands,
12    undo_redo_commands,
13};
14
15use unicode_segmentation::UnicodeSegmentation;
16
17use crate::convert::{to_i64, to_usize};
18use crate::events::DocumentEvent;
19use crate::flow::{CellRange, FlowElement, FrameRef, SelectionKind, TableCellRef};
20use crate::fragment::DocumentFragment;
21use crate::inner::{CursorData, QueuedEvents, TextDocumentInner};
22use crate::text_table::TextTable;
23use crate::{BlockFormat, FrameFormat, MoveMode, MoveOperation, SelectionType, TextFormat};
24
25use crate::document::get_main_frame_id;
26
27/// Compute the maximum valid cursor position from document stats.
28///
29/// Cursor positions include block separators (one between each pair of adjacent
30/// blocks), but `character_count` does not. The max position is therefore
31/// `character_count + (block_count - 1)`.
32fn max_cursor_position(stats: &frontend::document_inspection::DocumentStatsDto) -> usize {
33    let chars = to_usize(stats.character_count);
34    let blocks = to_usize(stats.block_count);
35    if blocks > 1 {
36        chars + blocks - 1
37    } else {
38        chars
39    }
40}
41
42/// A cursor into a [`TextDocument`](crate::TextDocument).
43///
44/// Multiple cursors can coexist on the same document (like Qt's `QTextCursor`).
45/// When any cursor edits text, all other cursors' positions are automatically
46/// adjusted by the document.
47///
48/// Cloning a cursor creates an **independent** cursor at the same position.
49pub struct TextCursor {
50    pub(crate) doc: Arc<Mutex<TextDocumentInner>>,
51    pub(crate) data: Arc<Mutex<CursorData>>,
52}
53
54impl Clone for TextCursor {
55    fn clone(&self) -> Self {
56        let (position, anchor) = {
57            let d = self.data.lock();
58            (d.position, d.anchor)
59        };
60        let data = {
61            let mut inner = self.doc.lock();
62            let data = Arc::new(Mutex::new(CursorData {
63                position,
64                anchor,
65                cell_selection_override: None,
66            }));
67            inner.cursors.push(Arc::downgrade(&data));
68            data
69        };
70        TextCursor {
71            doc: self.doc.clone(),
72            data,
73        }
74    }
75}
76
77impl TextCursor {
78    // ── Helpers (called while doc lock is NOT held) ──────────
79
80    fn read_cursor(&self) -> (usize, usize) {
81        let d = self.data.lock();
82        (d.position, d.anchor)
83    }
84
85    /// Common post-edit bookkeeping: adjust all cursors, set this cursor to
86    /// `new_pos`, mark modified, invalidate text cache, queue a
87    /// `ContentsChanged` event, and return the queued events for dispatch.
88    fn finish_edit(
89        &self,
90        inner: &mut TextDocumentInner,
91        edit_pos: usize,
92        removed: usize,
93        new_pos: usize,
94        blocks_affected: usize,
95    ) -> QueuedEvents {
96        self.finish_edit_ext(inner, edit_pos, removed, new_pos, blocks_affected, true)
97    }
98
99    fn finish_edit_ext(
100        &self,
101        inner: &mut TextDocumentInner,
102        edit_pos: usize,
103        removed: usize,
104        new_pos: usize,
105        blocks_affected: usize,
106        flow_may_change: bool,
107    ) -> QueuedEvents {
108        // Defensive: a use case can return new_position < edit_pos when
109        // invoked through a stale or out-of-range cursor (e.g. after an
110        // undo restores a state where the previously-saved cursor position
111        // is no longer valid — fuzz finds this). Treat the edit as adding
112        // 0 chars rather than overflowing; the cursor still moves to
113        // `new_pos` below.
114        let added = new_pos.saturating_sub(edit_pos);
115        inner.adjust_cursors(edit_pos, removed, added);
116        {
117            let mut d = self.data.lock();
118            d.position = new_pos;
119            d.anchor = new_pos;
120        }
121        inner.modified = true;
122        inner.invalidate_text_cache();
123        inner.rehighlight_affected(edit_pos);
124        inner.queue_event(DocumentEvent::ContentsChanged {
125            position: edit_pos,
126            chars_removed: removed,
127            chars_added: added,
128            blocks_affected,
129        });
130        inner.check_block_count_changed();
131        if flow_may_change {
132            inner.check_flow_changed();
133        }
134        self.queue_undo_redo_event(inner)
135    }
136
137    // ── Position & selection ─────────────────────────────────
138
139    /// Current cursor position (between characters).
140    pub fn position(&self) -> usize {
141        self.data.lock().position
142    }
143
144    /// Anchor position. Equal to `position()` when no selection.
145    pub fn anchor(&self) -> usize {
146        self.data.lock().anchor
147    }
148
149    /// Returns true if there is a selection.
150    pub fn has_selection(&self) -> bool {
151        let d = self.data.lock();
152        d.position != d.anchor
153    }
154
155    /// Start of the selection (min of position and anchor).
156    pub fn selection_start(&self) -> usize {
157        let d = self.data.lock();
158        d.position.min(d.anchor)
159    }
160
161    /// End of the selection (max of position and anchor).
162    pub fn selection_end(&self) -> usize {
163        let d = self.data.lock();
164        d.position.max(d.anchor)
165    }
166
167    /// Get the selected text. Returns empty string if no selection.
168    pub fn selected_text(&self) -> Result<String> {
169        let (pos, anchor) = self.read_cursor();
170        if pos == anchor {
171            return Ok(String::new());
172        }
173        let start = pos.min(anchor);
174        let len = pos.max(anchor) - start;
175        let inner = self.doc.lock();
176        let dto = frontend::document_inspection::GetTextAtPositionDto {
177            position: to_i64(start),
178            length: to_i64(len),
179        };
180        let result = document_inspection_commands::get_text_at_position(&inner.ctx, &dto)?;
181        Ok(result.text)
182    }
183
184    /// Collapse the selection by moving anchor to position.
185    pub fn clear_selection(&self) {
186        let mut d = self.data.lock();
187        d.anchor = d.position;
188    }
189
190    // ── Boundary queries ─────────────────────────────────────
191
192    /// True if the cursor is at the start of a block.
193    pub fn at_block_start(&self) -> bool {
194        let pos = self.position();
195        let inner = self.doc.lock();
196        let dto = frontend::document_inspection::GetBlockAtPositionDto {
197            position: to_i64(pos),
198        };
199        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
200            pos == to_usize(info.block_start)
201        } else {
202            false
203        }
204    }
205
206    /// True if the cursor is at the end of a block.
207    pub fn at_block_end(&self) -> bool {
208        let pos = self.position();
209        let inner = self.doc.lock();
210        let dto = frontend::document_inspection::GetBlockAtPositionDto {
211            position: to_i64(pos),
212        };
213        if let Ok(info) = document_inspection_commands::get_block_at_position(&inner.ctx, &dto) {
214            pos == to_usize(info.block_start) + to_usize(info.block_length)
215        } else {
216            false
217        }
218    }
219
220    /// True if the cursor is at position 0.
221    pub fn at_start(&self) -> bool {
222        self.data.lock().position == 0
223    }
224
225    /// True if the cursor is at the very end of the document.
226    pub fn at_end(&self) -> bool {
227        let pos = self.position();
228        let inner = self.doc.lock();
229        let stats = document_inspection_commands::get_document_stats(&inner.ctx).unwrap_or({
230            frontend::document_inspection::DocumentStatsDto {
231                character_count: 0,
232                word_count: 0,
233                block_count: 0,
234                frame_count: 0,
235                image_count: 0,
236                list_count: 0,
237                table_count: 0,
238            }
239        });
240        pos >= max_cursor_position(&stats)
241    }
242
243    /// The block number (0-indexed) containing the cursor.
244    pub fn block_number(&self) -> usize {
245        let pos = self.position();
246        let inner = self.doc.lock();
247        let dto = frontend::document_inspection::GetBlockAtPositionDto {
248            position: to_i64(pos),
249        };
250        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
251            .map(|info| to_usize(info.block_number))
252            .unwrap_or(0)
253    }
254
255    /// The cursor's column within the current block (0-indexed).
256    pub fn position_in_block(&self) -> usize {
257        let pos = self.position();
258        let inner = self.doc.lock();
259        let dto = frontend::document_inspection::GetBlockAtPositionDto {
260            position: to_i64(pos),
261        };
262        document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
263            .map(|info| pos.saturating_sub(to_usize(info.block_start)))
264            .unwrap_or(0)
265    }
266
267    // ── Movement ─────────────────────────────────────────────
268
269    /// Set the cursor to an absolute position.
270    ///
271    /// When extending a selection (`KeepAnchor`) across a table boundary,
272    /// the position is snapped to the adjacent block outside the table so
273    /// the entire table is "trapped" inside the selection range. This
274    /// mirrors LibreOffice's behaviour: partial table selections from
275    /// outside are not allowed; the table is always fully enclosed.
276    ///
277    /// The snap is skipped when:
278    /// - `mode` is `MoveAnchor` (plain click / move without selection)
279    /// - No adjacent block exists (table is first or last in the document)
280    pub fn set_position(&self, position: usize, mode: MoveMode) {
281        // Clamp to max document position (includes block separators)
282        let end = {
283            let inner = self.doc.lock();
284            document_inspection_commands::get_document_stats(&inner.ctx)
285                .map(|s| max_cursor_position(&s))
286                .unwrap_or(0)
287        };
288        let mut pos = position.min(end);
289
290        // Table-trap snap: when extending a selection, if one endpoint is
291        // inside a table and the other is outside, relocate the inside
292        // endpoint to the boundary of the adjacent block.
293        if mode == MoveMode::KeepAnchor {
294            let anchor = self.data.lock().anchor;
295            let pos_cell = self.table_cell_at(pos);
296            let anchor_cell = self.table_cell_at(anchor);
297            match (&pos_cell, &anchor_cell) {
298                (Some(tc), None) => {
299                    // Position is inside a table, anchor is outside.
300                    let before = anchor < pos;
301                    if let Some(boundary) = self.table_boundary_position(tc.table.id(), !before) {
302                        pos = boundary;
303                    }
304                }
305                (None, Some(tc)) => {
306                    // Anchor is inside a table, position is outside.
307                    // Snap the position so the table is enclosed.
308                    let before = pos < anchor;
309                    if let Some(boundary) = self.table_boundary_position(tc.table.id(), !before) {
310                        pos = boundary;
311                    }
312                }
313                _ => {}
314            }
315        }
316
317        {
318            let mut d = self.data.lock();
319            d.position = pos;
320            if mode == MoveMode::MoveAnchor {
321                d.anchor = pos;
322            }
323            d.cell_selection_override = None;
324        }
325        // Snap forward to the nearest grapheme cluster boundary so
326        // a caller passing an arbitrary scalar index (e.g. computed
327        // from a hit-test or a plain-text search) never leaves the
328        // cursor inside a multi-scalar grapheme cluster.
329        self.snap_position_to_grapheme_boundary();
330    }
331
332    /// Move the cursor by a semantic operation.
333    ///
334    /// `n` is used as a repeat count for character-level movements
335    /// (`NextCharacter`, `PreviousCharacter`, `Left`, `Right`).
336    /// For all other operations it is ignored. Returns `true` if the cursor moved.
337    pub fn move_position(&self, operation: MoveOperation, mode: MoveMode, n: usize) -> bool {
338        let old_pos = self.position();
339        let target = self.resolve_move(operation, n);
340        self.set_position(target, mode);
341        self.position() != old_pos
342    }
343
344    /// Select a region relative to the cursor position.
345    pub fn select(&self, selection: SelectionType) {
346        match selection {
347            SelectionType::Document => {
348                let end = {
349                    let inner = self.doc.lock();
350                    document_inspection_commands::get_document_stats(&inner.ctx)
351                        .map(|s| max_cursor_position(&s))
352                        .unwrap_or(0)
353                };
354                let mut d = self.data.lock();
355                d.anchor = 0;
356                d.position = end;
357                d.cell_selection_override = None;
358            }
359            SelectionType::BlockUnderCursor | SelectionType::LineUnderCursor => {
360                let pos = self.position();
361                let inner = self.doc.lock();
362                let dto = frontend::document_inspection::GetBlockAtPositionDto {
363                    position: to_i64(pos),
364                };
365                if let Ok(info) =
366                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
367                {
368                    let start = to_usize(info.block_start);
369                    let end = start + to_usize(info.block_length);
370                    drop(inner);
371                    let mut d = self.data.lock();
372                    d.anchor = start;
373                    d.position = end;
374                    d.cell_selection_override = None;
375                }
376            }
377            SelectionType::WordUnderCursor => {
378                let pos = self.position();
379                let (word_start, word_end) = self.find_word_boundaries(pos);
380                let mut d = self.data.lock();
381                d.anchor = word_start;
382                d.position = word_end;
383                d.cell_selection_override = None;
384            }
385        }
386    }
387
388    // ── Text editing ─────────────────────────────────────────
389
390    /// Insert plain text at the cursor. Replaces selection if any.
391    pub fn insert_text(&self, text: &str) -> Result<()> {
392        let (pos, anchor) = self.read_cursor();
393
394        // Try direct insert first (handles same-block selection and no-selection cases)
395        let dto = frontend::document_editing::InsertTextDto {
396            position: to_i64(pos),
397            anchor: to_i64(anchor),
398            text: text.into(),
399        };
400
401        let queued = {
402            let mut inner = self.doc.lock();
403            let result = match document_editing_commands::insert_text(
404                &inner.ctx,
405                Some(inner.stack_id),
406                &dto,
407            ) {
408                Ok(r) => r,
409                Err(_) if pos != anchor => {
410                    // Cross-block selection: compose delete + insert as a single undo unit
411                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
412
413                    let del_dto = frontend::document_editing::DeleteTextDto {
414                        position: to_i64(pos),
415                        anchor: to_i64(anchor),
416                    };
417                    let del_result = document_editing_commands::delete_text(
418                        &inner.ctx,
419                        Some(inner.stack_id),
420                        &del_dto,
421                    )?;
422                    let del_pos = to_usize(del_result.new_position);
423
424                    let ins_dto = frontend::document_editing::InsertTextDto {
425                        position: to_i64(del_pos),
426                        anchor: to_i64(del_pos),
427                        text: text.into(),
428                    };
429                    let ins_result = document_editing_commands::insert_text(
430                        &inner.ctx,
431                        Some(inner.stack_id),
432                        &ins_dto,
433                    )?;
434
435                    undo_redo_commands::end_composite(&inner.ctx);
436                    ins_result
437                }
438                Err(e) => return Err(e.into()),
439            };
440
441            let edit_pos = pos.min(anchor);
442            let removed = pos.max(anchor) - edit_pos;
443            self.finish_edit_ext(
444                &mut inner,
445                edit_pos,
446                removed,
447                to_usize(result.new_position),
448                to_usize(result.blocks_affected),
449                false,
450            )
451        };
452        crate::inner::dispatch_queued_events(queued);
453        Ok(())
454    }
455
456    /// Insert text with a specific character format. Replaces selection if any.
457    pub fn insert_formatted_text(&self, text: &str, format: &TextFormat) -> Result<()> {
458        let (pos, anchor) = self.read_cursor();
459
460        let make_dto = |p: usize, a: usize| frontend::document_editing::InsertFormattedTextDto {
461            position: to_i64(p),
462            anchor: to_i64(a),
463            text: text.into(),
464            font_family: format.font_family.clone().unwrap_or_default(),
465            font_point_size: format.font_point_size.map(|v| v as i64).unwrap_or(0),
466            font_bold: format.font_bold.unwrap_or(false),
467            font_italic: format.font_italic.unwrap_or(false),
468            font_underline: format.font_underline.unwrap_or(false),
469            font_strikeout: format.font_strikeout.unwrap_or(false),
470        };
471
472        let queued = {
473            let mut inner = self.doc.lock();
474            let result = match document_editing_commands::insert_formatted_text(
475                &inner.ctx,
476                Some(inner.stack_id),
477                &make_dto(pos, anchor),
478            ) {
479                Ok(r) => r,
480                Err(_) if pos != anchor => {
481                    // Cross-block selection: compose delete + insert as a single undo unit
482                    undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
483
484                    let del_dto = frontend::document_editing::DeleteTextDto {
485                        position: to_i64(pos),
486                        anchor: to_i64(anchor),
487                    };
488                    let del_result = document_editing_commands::delete_text(
489                        &inner.ctx,
490                        Some(inner.stack_id),
491                        &del_dto,
492                    )?;
493                    let del_pos = to_usize(del_result.new_position);
494
495                    let ins_result = document_editing_commands::insert_formatted_text(
496                        &inner.ctx,
497                        Some(inner.stack_id),
498                        &make_dto(del_pos, del_pos),
499                    )?;
500
501                    undo_redo_commands::end_composite(&inner.ctx);
502                    ins_result
503                }
504                Err(e) => return Err(e.into()),
505            };
506
507            let edit_pos = pos.min(anchor);
508            let removed = pos.max(anchor) - edit_pos;
509            self.finish_edit_ext(
510                &mut inner,
511                edit_pos,
512                removed,
513                to_usize(result.new_position),
514                1,
515                false,
516            )
517        };
518        crate::inner::dispatch_queued_events(queued);
519        Ok(())
520    }
521
522    /// Insert a block break (new paragraph). Replaces selection if any.
523    pub fn insert_block(&self) -> Result<()> {
524        let (pos, anchor) = self.read_cursor();
525        let queued = {
526            let mut inner = self.doc.lock();
527
528            let (insert_pos, removed) = if pos != anchor {
529                // Selection active: delete first, then split (Word convention)
530                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
531                let del_dto = frontend::document_editing::DeleteTextDto {
532                    position: to_i64(pos),
533                    anchor: to_i64(anchor),
534                };
535                let del_result = document_editing_commands::delete_text(
536                    &inner.ctx,
537                    Some(inner.stack_id),
538                    &del_dto,
539                )?;
540                (
541                    to_usize(del_result.new_position),
542                    pos.max(anchor) - pos.min(anchor),
543                )
544            } else {
545                (pos, 0)
546            };
547
548            let dto = frontend::document_editing::InsertBlockDto {
549                position: to_i64(insert_pos),
550                anchor: to_i64(insert_pos),
551            };
552            let result =
553                document_editing_commands::insert_block(&inner.ctx, Some(inner.stack_id), &dto)?;
554
555            if pos != anchor {
556                undo_redo_commands::end_composite(&inner.ctx);
557            }
558
559            let edit_pos = pos.min(anchor);
560            self.finish_edit(
561                &mut inner,
562                edit_pos,
563                removed,
564                to_usize(result.new_position),
565                2,
566            )
567        };
568        crate::inner::dispatch_queued_events(queued);
569        Ok(())
570    }
571
572    /// Insert an HTML fragment at the cursor position. Replaces selection if any.
573    pub fn insert_html(&self, html: &str) -> Result<()> {
574        // Delegate to insert_fragment so table structure is preserved.
575        let frag = DocumentFragment::from_html(html);
576        self.insert_fragment(&frag)
577    }
578
579    /// Insert a Markdown fragment at the cursor position. Replaces selection if any.
580    pub fn insert_markdown(&self, markdown: &str) -> Result<()> {
581        let frag = DocumentFragment::from_markdown(markdown);
582        self.insert_fragment(&frag)
583    }
584
585    /// Insert a djot fragment at the cursor position. Replaces selection if any.
586    pub fn insert_djot(&self, djot: &str) -> Result<()> {
587        let frag = DocumentFragment::from_djot(djot);
588        self.insert_fragment(&frag)
589    }
590
591    /// Insert a document fragment at the cursor. Replaces selection if any.
592    pub fn insert_fragment(&self, fragment: &DocumentFragment) -> Result<()> {
593        let (pos, anchor) = self.read_cursor();
594        let queued = {
595            let mut inner = self.doc.lock();
596
597            let (insert_pos, removed) = if pos != anchor {
598                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
599                let del_dto = frontend::document_editing::DeleteTextDto {
600                    position: to_i64(pos),
601                    anchor: to_i64(anchor),
602                };
603                let del_result = document_editing_commands::delete_text(
604                    &inner.ctx,
605                    Some(inner.stack_id),
606                    &del_dto,
607                )?;
608                (
609                    to_usize(del_result.new_position),
610                    pos.max(anchor) - pos.min(anchor),
611                )
612            } else {
613                (pos, 0)
614            };
615
616            let dto = frontend::document_editing::InsertFragmentDto {
617                position: to_i64(insert_pos),
618                anchor: to_i64(insert_pos),
619                fragment_data: fragment.raw_data().into(),
620            };
621            let result =
622                document_editing_commands::insert_fragment(&inner.ctx, Some(inner.stack_id), &dto)?;
623
624            if pos != anchor {
625                undo_redo_commands::end_composite(&inner.ctx);
626            }
627
628            let edit_pos = pos.min(anchor);
629            self.finish_edit(
630                &mut inner,
631                edit_pos,
632                removed,
633                to_usize(result.new_position),
634                to_usize(result.blocks_added),
635            )
636        };
637        crate::inner::dispatch_queued_events(queued);
638        Ok(())
639    }
640
641    /// Extract the current selection as a [`DocumentFragment`].
642    pub fn selection(&self) -> DocumentFragment {
643        let (pos, anchor) = self.read_cursor();
644
645        // For cell/mixed selections, compute position/anchor that span the
646        // full cell range so ExtractFragment detects cross-cell correctly.
647        let (extract_pos, extract_anchor) = match self.selection_kind() {
648            SelectionKind::Cells(ref range) => match self.cell_range_positions(range) {
649                Some((start, end)) => (start, end),
650                None => return DocumentFragment::new(),
651            },
652            SelectionKind::Mixed {
653                ref cell_range,
654                text_before,
655                text_after,
656            } => {
657                let (cell_start, cell_end) = match self.cell_range_positions(cell_range) {
658                    Some(p) => p,
659                    None => return DocumentFragment::new(),
660                };
661                let start = if text_before {
662                    pos.min(anchor)
663                } else {
664                    cell_start
665                };
666                let end = if text_after {
667                    pos.max(anchor)
668                } else {
669                    cell_end
670                };
671                (start.min(cell_start), end.max(cell_end))
672            }
673            SelectionKind::None => return DocumentFragment::new(),
674            SelectionKind::Text => (pos, anchor),
675        };
676
677        if extract_pos == extract_anchor {
678            return DocumentFragment::new();
679        }
680
681        let inner = self.doc.lock();
682        let dto = frontend::document_inspection::ExtractFragmentDto {
683            position: to_i64(extract_pos),
684            anchor: to_i64(extract_anchor),
685        };
686        match document_inspection_commands::extract_fragment(&inner.ctx, &dto) {
687            Ok(result) => DocumentFragment::from_raw(result.fragment_data, result.plain_text),
688            Err(_) => DocumentFragment::new(),
689        }
690    }
691
692    /// Insert an image at the cursor. Replaces selection if any.
693    pub fn insert_image(&self, name: &str, width: u32, height: u32) -> Result<()> {
694        let (pos, anchor) = self.read_cursor();
695        let queued = {
696            let mut inner = self.doc.lock();
697
698            let (insert_pos, removed) = if pos != anchor {
699                undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
700                let del_dto = frontend::document_editing::DeleteTextDto {
701                    position: to_i64(pos),
702                    anchor: to_i64(anchor),
703                };
704                let del_result = document_editing_commands::delete_text(
705                    &inner.ctx,
706                    Some(inner.stack_id),
707                    &del_dto,
708                )?;
709                (
710                    to_usize(del_result.new_position),
711                    pos.max(anchor) - pos.min(anchor),
712                )
713            } else {
714                (pos, 0)
715            };
716
717            let dto = frontend::document_editing::InsertImageDto {
718                position: to_i64(insert_pos),
719                anchor: to_i64(insert_pos),
720                image_name: name.into(),
721                width: width as i64,
722                height: height as i64,
723            };
724            let result =
725                document_editing_commands::insert_image(&inner.ctx, Some(inner.stack_id), &dto)?;
726
727            if pos != anchor {
728                undo_redo_commands::end_composite(&inner.ctx);
729            }
730
731            let edit_pos = pos.min(anchor);
732            self.finish_edit_ext(
733                &mut inner,
734                edit_pos,
735                removed,
736                to_usize(result.new_position),
737                1,
738                false,
739            )
740        };
741        crate::inner::dispatch_queued_events(queued);
742        Ok(())
743    }
744
745    /// Insert a new frame at the cursor.
746    pub fn insert_frame(&self) -> Result<()> {
747        let (pos, anchor) = self.read_cursor();
748        let queued = {
749            let mut inner = self.doc.lock();
750            let dto = frontend::document_editing::InsertFrameDto {
751                position: to_i64(pos),
752                anchor: to_i64(anchor),
753            };
754            document_editing_commands::insert_frame(&inner.ctx, Some(inner.stack_id), &dto)?;
755            // Frame insertion adds structural content; adjust cursors and emit event.
756            // The backend doesn't return a new_position, so the cursor stays put.
757            inner.modified = true;
758            inner.invalidate_text_cache();
759            inner.rehighlight_affected(pos.min(anchor));
760            inner.queue_event(DocumentEvent::ContentsChanged {
761                position: pos.min(anchor),
762                chars_removed: 0,
763                chars_added: 0,
764                blocks_affected: 1,
765            });
766            inner.check_block_count_changed();
767            inner.check_flow_changed();
768            self.queue_undo_redo_event(&mut inner)
769        };
770        crate::inner::dispatch_queued_events(queued);
771        Ok(())
772    }
773
774    /// Insert a table at the cursor position.
775    ///
776    /// Creates a `rows × columns` table with empty cells.
777    /// The cursor moves into the first cell of the table.
778    /// Returns a handle to the created table.
779    pub fn insert_table(&self, rows: usize, columns: usize) -> Result<TextTable> {
780        let (pos, anchor) = self.read_cursor();
781        let (table_id, queued) = {
782            let mut inner = self.doc.lock();
783            let dto = frontend::document_editing::InsertTableDto {
784                position: to_i64(pos),
785                anchor: to_i64(anchor),
786                rows: to_i64(rows),
787                columns: to_i64(columns),
788            };
789            let result =
790                document_editing_commands::insert_table(&inner.ctx, Some(inner.stack_id), &dto)?;
791            let new_pos = to_usize(result.new_position);
792            let table_id = to_usize(result.table_id);
793            inner.adjust_cursors(pos.min(anchor), 0, new_pos - pos.min(anchor));
794            {
795                let mut d = self.data.lock();
796                d.position = new_pos;
797                d.anchor = new_pos;
798            }
799            inner.modified = true;
800            inner.invalidate_text_cache();
801            inner.rehighlight_affected(pos.min(anchor));
802            inner.queue_event(DocumentEvent::ContentsChanged {
803                position: pos.min(anchor),
804                chars_removed: 0,
805                chars_added: new_pos - pos.min(anchor),
806                blocks_affected: 1,
807            });
808            inner.check_block_count_changed();
809            inner.check_flow_changed();
810            (table_id, self.queue_undo_redo_event(&mut inner))
811        };
812        crate::inner::dispatch_queued_events(queued);
813        Ok(TextTable {
814            doc: self.doc.clone(),
815            table_id,
816        })
817    }
818
819    /// Returns the table the cursor is currently inside, if any.
820    ///
821    /// Returns `None` if the cursor is in the main document flow
822    /// (not inside a table cell).
823    pub fn current_table(&self) -> Option<TextTable> {
824        self.current_table_cell().map(|c| c.table)
825    }
826
827    /// Returns the table cell the cursor is currently inside, if any.
828    ///
829    /// Returns `None` if the cursor is not inside a table cell.
830    /// When `Some`, provides the table, row, and column.
831    pub fn current_table_cell(&self) -> Option<TableCellRef> {
832        let pos = self.position();
833        let inner = self.doc.lock();
834        // Find the block at cursor position
835        let dto = frontend::document_inspection::GetBlockAtPositionDto {
836            position: to_i64(pos),
837        };
838        let block_info =
839            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
840
841        // When position < block_start, the cursor sits on the separator between
842        // the previous block and this one. Visually the cursor belongs to the
843        // end of the previous block, so look up that block instead.
844        let block_id = if to_i64(pos) < block_info.block_start && pos > 0 {
845            let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
846                position: to_i64(pos - 1),
847            };
848            let prev_info =
849                document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto).ok()?;
850            prev_info.block_id as usize
851        } else {
852            block_info.block_id as usize
853        };
854
855        let block = crate::text_block::TextBlock {
856            doc: self.doc.clone(),
857            block_id,
858        };
859        // Release inner lock before calling table_cell() which also locks
860        drop(inner);
861        block.table_cell()
862    }
863
864    // ── Frame / blockquote queries ──────────
865
866    /// The innermost frame enclosing the cursor's current block, or `None`
867    /// if the cursor sits directly in the root frame (no enclosing
868    /// sub-frame). The returned `depth` is the nesting level from the root
869    /// (1 for a direct child of root, 2 for a grandchild, etc.).
870    pub fn current_frame(&self) -> Option<FrameRef> {
871        let pos = self.position();
872        let inner = self.doc.lock();
873        let dto = frontend::document_inspection::GetBlockAtPositionDto {
874            position: to_i64(pos),
875        };
876        let block_info =
877            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
878        let block_id = block_info.block_id as u64;
879        cursor_frame_ref(&inner, block_id)
880    }
881
882    /// True if the cursor's block lives inside any blockquote frame
883    /// (at any nesting level).
884    pub fn is_in_blockquote(&self) -> bool {
885        self.current_blockquote_frame_id().is_some()
886    }
887
888    /// Id of the innermost blockquote frame enclosing the cursor's block,
889    /// or `None` if not in a blockquote.
890    pub fn current_blockquote_frame_id(&self) -> Option<usize> {
891        let pos = self.position();
892        let inner = self.doc.lock();
893        let dto = frontend::document_inspection::GetBlockAtPositionDto {
894            position: to_i64(pos),
895        };
896        let block_info =
897            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
898        innermost_blockquote_frame_id(&inner, block_info.block_id as u64)
899    }
900
901    /// Nesting depth of the cursor inside blockquote frames: 0 = not in
902    /// any quote, 1 = top-level quote, 2 = quote inside a quote, …
903    pub fn blockquote_depth_at_cursor(&self) -> usize {
904        let pos = self.position();
905        let inner = self.doc.lock();
906        let dto = frontend::document_inspection::GetBlockAtPositionDto {
907            position: to_i64(pos),
908        };
909        let Some(block_info) =
910            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()
911        else {
912            return 0;
913        };
914        blockquote_depth_for_block(&inner, block_info.block_id as u64)
915    }
916
917    /// True iff the cursor's block is the first positive entry in its
918    /// owning frame's `child_order`. Used by the keyboard handler to
919    /// decide whether Backspace should unwrap the enclosing frame.
920    /// A single-block frame returns true for both `is_first_*` and
921    /// `is_last_*`.
922    pub fn is_first_block_in_current_frame(&self) -> bool {
923        matches!(
924            block_position_in_current_frame(self),
925            Some(BlockEdge::First) | Some(BlockEdge::OnlyOne)
926        )
927    }
928
929    /// True iff the cursor's block is the last positive entry in its
930    /// owning frame's `child_order`. Used by the keyboard handler to
931    /// decide whether forward Delete should unwrap the enclosing frame.
932    pub fn is_last_block_in_current_frame(&self) -> bool {
933        matches!(
934            block_position_in_current_frame(self),
935            Some(BlockEdge::Last) | Some(BlockEdge::OnlyOne)
936        )
937    }
938
939    /// True iff the block at the cursor has no characters of content.
940    /// Used by the Enter handler to decide whether to exit a blockquote.
941    pub fn current_block_is_empty(&self) -> bool {
942        let pos = self.position();
943        let inner = self.doc.lock();
944        let dto = frontend::document_inspection::GetBlockAtPositionDto {
945            position: to_i64(pos),
946        };
947        let Some(block_info) =
948            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()
949        else {
950            return false;
951        };
952        let store = inner.ctx.db_context.get_store();
953        let block_entity = store
954            .blocks
955            .read()
956            .get(&(block_info.block_id as common::types::EntityId))
957            .cloned();
958        match block_entity {
959            Some(b) => {
960                let len = common::database::rope_helpers::block_char_length(&b, store);
961                len == 0
962            }
963            None => false,
964        }
965    }
966
967    /// True iff the cursor's anchor and head sit in different frames.
968    /// Used by the toolbar to disable the "toggle blockquote" button on
969    /// selections that cross frame boundaries.
970    pub fn selection_spans_multiple_frames(&self) -> bool {
971        let (pos, anchor) = self.read_cursor();
972        if pos == anchor {
973            return false;
974        }
975        let inner = self.doc.lock();
976        let pos_dto = frontend::document_inspection::GetBlockAtPositionDto {
977            position: to_i64(pos),
978        };
979        let anchor_dto = frontend::document_inspection::GetBlockAtPositionDto {
980            position: to_i64(anchor),
981        };
982        let Some(pos_block) =
983            document_inspection_commands::get_block_at_position(&inner.ctx, &pos_dto).ok()
984        else {
985            return false;
986        };
987        let Some(anchor_block) =
988            document_inspection_commands::get_block_at_position(&inner.ctx, &anchor_dto).ok()
989        else {
990            return false;
991        };
992        let pos_owner = crate::text_block::find_parent_frame(&inner, pos_block.block_id as u64);
993        let anchor_owner =
994            crate::text_block::find_parent_frame(&inner, anchor_block.block_id as u64);
995        pos_owner != anchor_owner
996    }
997
998    // ── Blockquote mutations ──────────
999
1000    /// Wrap the current block (or the blocks in the current selection)
1001    /// in a new blockquote frame nested inside the cursor's current
1002    /// parent frame. Returns an error if the selection spans multiple
1003    /// frames.
1004    pub fn wrap_selection_in_blockquote(&self) -> Result<()> {
1005        if self.selection_spans_multiple_frames() {
1006            return Err(DocumentError::InvalidArgument(
1007                "Cannot wrap selection in blockquote: selection spans multiple frames".into(),
1008            ));
1009        }
1010        let (start_block_id, end_block_id) = self.resolve_selection_block_range()?;
1011        let dto = frontend::document_editing::WrapBlocksInFrameDto {
1012            start_block_id: start_block_id as i64,
1013            end_block_id: end_block_id as i64,
1014            position: Some(frontend::document_editing::FramePosition::InFlow),
1015            top_margin: None,
1016            bottom_margin: None,
1017            left_margin: None,
1018            right_margin: None,
1019            padding: None,
1020            border: None,
1021            is_blockquote: Some(true),
1022        };
1023        let queued = {
1024            let mut inner = self.doc.lock();
1025            let _result = document_editing_commands::wrap_blocks_in_frame(
1026                &inner.ctx,
1027                Some(inner.stack_id),
1028                &dto,
1029            )?;
1030            inner.modified = true;
1031            // Frame-structure change: blocks didn't move and no text
1032            // changed, but block left-margins shift visually. Fire
1033            // FormatChanged (kind = Block) so the widget triggers a
1034            // paragraph relayout — same pattern as list operations
1035            // ([`add_block_to_list`] et al.). `ContentsChanged` with
1036            // chars_added/removed = 0 was misleading and caused the
1037            // incremental relayout to no-op until the next full repaint.
1038            inner.queue_event(DocumentEvent::FormatChanged {
1039                position: 0,
1040                length: 0,
1041                kind: crate::flow::FormatChangeKind::Block,
1042            });
1043            self.queue_undo_redo_event(&mut inner)
1044        };
1045        crate::inner::dispatch_queued_events(queued);
1046        Ok(())
1047    }
1048
1049    /// Wrap the current block in a new blockquote frame at the current
1050    /// nesting level. Equivalent to `wrap_selection_in_blockquote()`
1051    /// when there is no selection.
1052    pub fn insert_blockquote(&self) -> Result<()> {
1053        self.wrap_selection_in_blockquote()
1054    }
1055
1056    /// If the cursor is inside any blockquote, unwrap the innermost one
1057    /// (lift its blocks into the parent frame and delete the frame).
1058    /// Otherwise, wrap the current block / selection in a new
1059    /// blockquote. Mirrors the toggle behaviour of a toolbar button.
1060    pub fn toggle_blockquote(&self) -> Result<()> {
1061        if let Some(frame_id) = self.current_blockquote_frame_id() {
1062            self.unwrap_frame_by_id(frame_id)
1063        } else {
1064            self.wrap_selection_in_blockquote()
1065        }
1066    }
1067
1068    /// Unwrap the innermost frame enclosing the cursor (any frame, not
1069    /// just blockquotes). Errors if the cursor's block sits in the root
1070    /// frame.
1071    pub fn unwrap_current_frame(&self) -> Result<()> {
1072        let frame_ref = self.current_frame().ok_or_else(|| {
1073            DocumentError::InvalidCursorContext("Cursor is not inside any sub-frame".into())
1074        })?;
1075        self.unwrap_frame_by_id(frame_ref.frame_id)
1076    }
1077
1078    /// Extract the cursor's current block from its innermost enclosing
1079    /// blockquote frame, lifting it one nesting level. If the cursor is
1080    /// not in a blockquote, errors.
1081    pub fn unwrap_current_block_from_blockquote(&self) -> Result<()> {
1082        if self.current_blockquote_frame_id().is_none() {
1083            return Err(DocumentError::InvalidCursorContext(
1084                "Cursor is not inside a blockquote".into(),
1085            ));
1086        }
1087        let block_id = self.current_block_id_for_mutation()?;
1088        let dto = frontend::document_editing::UnwrapBlockFromFrameDto {
1089            block_id: block_id as i64,
1090        };
1091        let queued = {
1092            let mut inner = self.doc.lock();
1093            let _result = document_editing_commands::unwrap_block_from_frame(
1094                &inner.ctx,
1095                Some(inner.stack_id),
1096                &dto,
1097            )?;
1098            inner.modified = true;
1099            // See note in `wrap_selection_in_blockquote` — frame-structure
1100            // change without text mutation; fire FormatChanged so the
1101            // widget relayouts paragraph margins.
1102            inner.queue_event(DocumentEvent::FormatChanged {
1103                position: 0,
1104                length: 0,
1105                kind: crate::flow::FormatChangeKind::Block,
1106            });
1107            self.queue_undo_redo_event(&mut inner)
1108        };
1109        crate::inner::dispatch_queued_events(queued);
1110        Ok(())
1111    }
1112
1113    /// Wrap the current block in a new blockquote frame. If the cursor
1114    /// is already inside a blockquote, this creates a deeper nested
1115    /// quote (depth + 1). If outside, this creates a top-level quote.
1116    pub fn increase_blockquote_depth(&self) -> Result<()> {
1117        self.wrap_selection_in_blockquote()
1118    }
1119
1120    /// Pop the cursor out of one nesting level of blockquotes. If the
1121    /// cursor is in a depth-N quote with multiple blocks, the current
1122    /// block is extracted (splitting the quote if needed). If the
1123    /// current block is the only one in the quote, the whole quote is
1124    /// unwrapped.
1125    pub fn decrease_blockquote_depth(&self) -> Result<()> {
1126        if self.current_blockquote_frame_id().is_none() {
1127            return Err(DocumentError::InvalidCursorContext(
1128                "Cursor is not inside a blockquote to decrease depth".into(),
1129            ));
1130        }
1131        self.unwrap_current_block_from_blockquote()
1132    }
1133
1134    fn unwrap_frame_by_id(&self, frame_id: usize) -> Result<()> {
1135        let dto = frontend::document_editing::UnwrapFrameDto {
1136            frame_id: frame_id as i64,
1137        };
1138        let queued = {
1139            let mut inner = self.doc.lock();
1140            let _result =
1141                document_editing_commands::unwrap_frame(&inner.ctx, Some(inner.stack_id), &dto)?;
1142            inner.modified = true;
1143            // See note in `wrap_selection_in_blockquote` — frame-structure
1144            // change without text mutation; fire FormatChanged so the
1145            // widget relayouts paragraph margins.
1146            inner.queue_event(DocumentEvent::FormatChanged {
1147                position: 0,
1148                length: 0,
1149                kind: crate::flow::FormatChangeKind::Block,
1150            });
1151            self.queue_undo_redo_event(&mut inner)
1152        };
1153        crate::inner::dispatch_queued_events(queued);
1154        Ok(())
1155    }
1156
1157    fn current_block_id_for_mutation(&self) -> Result<usize> {
1158        let pos = self.position();
1159        let inner = self.doc.lock();
1160        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1161            position: to_i64(pos),
1162        };
1163        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1164            .map_err(|e| anyhow::anyhow!("get_block_at_position: {}", e))?;
1165        Ok(block_info.block_id as usize)
1166    }
1167
1168    fn resolve_selection_block_range(&self) -> Result<(usize, usize)> {
1169        let (pos, anchor) = self.read_cursor();
1170        let lo = pos.min(anchor);
1171        let hi = pos.max(anchor);
1172        let inner = self.doc.lock();
1173        let lo_dto = frontend::document_inspection::GetBlockAtPositionDto {
1174            position: to_i64(lo),
1175        };
1176        let hi_dto = frontend::document_inspection::GetBlockAtPositionDto {
1177            position: to_i64(hi),
1178        };
1179        let lo_block = document_inspection_commands::get_block_at_position(&inner.ctx, &lo_dto)
1180            .map_err(|e| anyhow::anyhow!("get_block_at_position(start): {}", e))?;
1181        let hi_block = document_inspection_commands::get_block_at_position(&inner.ctx, &hi_dto)
1182            .map_err(|e| anyhow::anyhow!("get_block_at_position(end): {}", e))?;
1183        Ok((lo_block.block_id as usize, hi_block.block_id as usize))
1184    }
1185
1186    // ── Table structure mutations (explicit-ID) ──────────
1187
1188    /// Remove a table from the document by its ID.
1189    pub fn remove_table(&self, table_id: usize) -> Result<()> {
1190        let queued = {
1191            let mut inner = self.doc.lock();
1192            let dto = frontend::document_editing::RemoveTableDto {
1193                table_id: to_i64(table_id),
1194            };
1195            document_editing_commands::remove_table(&inner.ctx, Some(inner.stack_id), &dto)?;
1196            inner.modified = true;
1197            inner.invalidate_text_cache();
1198            inner.rehighlight_all();
1199            inner.check_block_count_changed();
1200            inner.check_flow_changed();
1201            self.queue_undo_redo_event(&mut inner)
1202        };
1203        crate::inner::dispatch_queued_events(queued);
1204        Ok(())
1205    }
1206
1207    /// Insert a row into a table at the given index.
1208    pub fn insert_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
1209        let queued = {
1210            let mut inner = self.doc.lock();
1211            let dto = frontend::document_editing::InsertTableRowDto {
1212                table_id: to_i64(table_id),
1213                row_index: to_i64(row_index),
1214            };
1215            document_editing_commands::insert_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
1216            inner.modified = true;
1217            inner.invalidate_text_cache();
1218            inner.rehighlight_all();
1219            inner.check_block_count_changed();
1220            self.queue_undo_redo_event(&mut inner)
1221        };
1222        crate::inner::dispatch_queued_events(queued);
1223        Ok(())
1224    }
1225
1226    /// Insert a column into a table at the given index.
1227    pub fn insert_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
1228        let queued = {
1229            let mut inner = self.doc.lock();
1230            let dto = frontend::document_editing::InsertTableColumnDto {
1231                table_id: to_i64(table_id),
1232                column_index: to_i64(column_index),
1233            };
1234            document_editing_commands::insert_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
1235            inner.modified = true;
1236            inner.invalidate_text_cache();
1237            inner.rehighlight_all();
1238            inner.check_block_count_changed();
1239            self.queue_undo_redo_event(&mut inner)
1240        };
1241        crate::inner::dispatch_queued_events(queued);
1242        Ok(())
1243    }
1244
1245    /// Remove a row from a table. Fails if only one row remains.
1246    pub fn remove_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
1247        let queued = {
1248            let mut inner = self.doc.lock();
1249            let dto = frontend::document_editing::RemoveTableRowDto {
1250                table_id: to_i64(table_id),
1251                row_index: to_i64(row_index),
1252            };
1253            document_editing_commands::remove_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
1254            inner.modified = true;
1255            inner.invalidate_text_cache();
1256            inner.rehighlight_all();
1257            inner.check_block_count_changed();
1258            self.queue_undo_redo_event(&mut inner)
1259        };
1260        crate::inner::dispatch_queued_events(queued);
1261        Ok(())
1262    }
1263
1264    /// Remove a column from a table. Fails if only one column remains.
1265    pub fn remove_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
1266        let queued = {
1267            let mut inner = self.doc.lock();
1268            let dto = frontend::document_editing::RemoveTableColumnDto {
1269                table_id: to_i64(table_id),
1270                column_index: to_i64(column_index),
1271            };
1272            document_editing_commands::remove_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
1273            inner.modified = true;
1274            inner.invalidate_text_cache();
1275            inner.rehighlight_all();
1276            inner.check_block_count_changed();
1277            self.queue_undo_redo_event(&mut inner)
1278        };
1279        crate::inner::dispatch_queued_events(queued);
1280        Ok(())
1281    }
1282
1283    /// Merge a rectangular range of cells within a table.
1284    pub fn merge_table_cells(
1285        &self,
1286        table_id: usize,
1287        start_row: usize,
1288        start_column: usize,
1289        end_row: usize,
1290        end_column: usize,
1291    ) -> Result<()> {
1292        let queued = {
1293            let mut inner = self.doc.lock();
1294            let dto = frontend::document_editing::MergeTableCellsDto {
1295                table_id: to_i64(table_id),
1296                start_row: to_i64(start_row),
1297                start_column: to_i64(start_column),
1298                end_row: to_i64(end_row),
1299                end_column: to_i64(end_column),
1300            };
1301            document_editing_commands::merge_table_cells(&inner.ctx, Some(inner.stack_id), &dto)?;
1302            inner.modified = true;
1303            inner.invalidate_text_cache();
1304            inner.rehighlight_all();
1305            inner.check_block_count_changed();
1306            self.queue_undo_redo_event(&mut inner)
1307        };
1308        crate::inner::dispatch_queued_events(queued);
1309        Ok(())
1310    }
1311
1312    /// Split a previously merged cell.
1313    pub fn split_table_cell(
1314        &self,
1315        cell_id: usize,
1316        split_rows: usize,
1317        split_columns: usize,
1318    ) -> Result<()> {
1319        let queued = {
1320            let mut inner = self.doc.lock();
1321            let dto = frontend::document_editing::SplitTableCellDto {
1322                cell_id: to_i64(cell_id),
1323                split_rows: to_i64(split_rows),
1324                split_columns: to_i64(split_columns),
1325            };
1326            document_editing_commands::split_table_cell(&inner.ctx, Some(inner.stack_id), &dto)?;
1327            inner.modified = true;
1328            inner.invalidate_text_cache();
1329            inner.rehighlight_all();
1330            inner.check_block_count_changed();
1331            self.queue_undo_redo_event(&mut inner)
1332        };
1333        crate::inner::dispatch_queued_events(queued);
1334        Ok(())
1335    }
1336
1337    // ── Table formatting (explicit-ID) ───────────────────
1338
1339    /// Set formatting on a table.
1340    pub fn set_table_format(
1341        &self,
1342        table_id: usize,
1343        format: &crate::flow::TableFormat,
1344    ) -> Result<()> {
1345        let queued = {
1346            let mut inner = self.doc.lock();
1347            let dto = format.to_set_dto(table_id);
1348            document_formatting_commands::set_table_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1349            inner.modified = true;
1350            inner.queue_event(DocumentEvent::FormatChanged {
1351                position: 0,
1352                length: 0,
1353                kind: crate::flow::FormatChangeKind::Block,
1354            });
1355            self.queue_undo_redo_event(&mut inner)
1356        };
1357        crate::inner::dispatch_queued_events(queued);
1358        Ok(())
1359    }
1360
1361    /// Set formatting on a table cell.
1362    pub fn set_table_cell_format(
1363        &self,
1364        cell_id: usize,
1365        format: &crate::flow::CellFormat,
1366    ) -> Result<()> {
1367        let queued = {
1368            let mut inner = self.doc.lock();
1369            let dto = format.to_set_dto(cell_id);
1370            document_formatting_commands::set_table_cell_format(
1371                &inner.ctx,
1372                Some(inner.stack_id),
1373                &dto,
1374            )?;
1375            inner.modified = true;
1376            inner.queue_event(DocumentEvent::FormatChanged {
1377                position: 0,
1378                length: 0,
1379                kind: crate::flow::FormatChangeKind::Block,
1380            });
1381            self.queue_undo_redo_event(&mut inner)
1382        };
1383        crate::inner::dispatch_queued_events(queued);
1384        Ok(())
1385    }
1386
1387    // ── Table convenience (position-based) ───────────────
1388
1389    /// Remove the table the cursor is currently inside.
1390    /// Returns an error if the cursor is not inside a table.
1391    pub fn remove_current_table(&self) -> Result<()> {
1392        let table = self.current_table().ok_or_else(|| {
1393            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1394        })?;
1395        self.remove_table(table.id())
1396    }
1397
1398    /// Insert a row above the cursor's current row.
1399    /// Returns an error if the cursor is not inside a table.
1400    pub fn insert_row_above(&self) -> Result<()> {
1401        let cell_ref = self.current_table_cell().ok_or_else(|| {
1402            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1403        })?;
1404        self.insert_table_row(cell_ref.table.id(), cell_ref.row)
1405    }
1406
1407    /// Insert a row below the cursor's current row.
1408    /// Returns an error if the cursor is not inside a table.
1409    pub fn insert_row_below(&self) -> Result<()> {
1410        let cell_ref = self.current_table_cell().ok_or_else(|| {
1411            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1412        })?;
1413        self.insert_table_row(cell_ref.table.id(), cell_ref.row + 1)
1414    }
1415
1416    /// Insert a column before the cursor's current column.
1417    /// Returns an error if the cursor is not inside a table.
1418    pub fn insert_column_before(&self) -> Result<()> {
1419        let cell_ref = self.current_table_cell().ok_or_else(|| {
1420            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1421        })?;
1422        self.insert_table_column(cell_ref.table.id(), cell_ref.column)
1423    }
1424
1425    /// Insert a column after the cursor's current column.
1426    /// Returns an error if the cursor is not inside a table.
1427    pub fn insert_column_after(&self) -> Result<()> {
1428        let cell_ref = self.current_table_cell().ok_or_else(|| {
1429            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1430        })?;
1431        self.insert_table_column(cell_ref.table.id(), cell_ref.column + 1)
1432    }
1433
1434    /// Remove the row at the cursor's current position.
1435    /// Returns an error if the cursor is not inside a table.
1436    pub fn remove_current_row(&self) -> Result<()> {
1437        let cell_ref = self.current_table_cell().ok_or_else(|| {
1438            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1439        })?;
1440        self.remove_table_row(cell_ref.table.id(), cell_ref.row)
1441    }
1442
1443    /// Remove the column at the cursor's current position.
1444    /// Returns an error if the cursor is not inside a table.
1445    pub fn remove_current_column(&self) -> Result<()> {
1446        let cell_ref = self.current_table_cell().ok_or_else(|| {
1447            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1448        })?;
1449        self.remove_table_column(cell_ref.table.id(), cell_ref.column)
1450    }
1451
1452    /// Merge cells spanned by the current selection.
1453    ///
1454    /// Both cursor position and anchor must be inside the same table.
1455    /// The cell range is derived from the cells at position and anchor.
1456    /// Returns an error if the cursor is not inside a table or position
1457    /// and anchor are in different tables.
1458    pub fn merge_selected_cells(&self) -> Result<()> {
1459        let pos_cell = self.current_table_cell().ok_or_else(|| {
1460            DocumentError::InvalidCursorContext("cursor position is not inside a table".into())
1461        })?;
1462
1463        // Get anchor cell
1464        let (_pos, anchor) = self.read_cursor();
1465        let anchor_cell = {
1466            // Create a temporary block handle at the anchor position
1467            let inner = self.doc.lock();
1468            let dto = frontend::document_inspection::GetBlockAtPositionDto {
1469                position: to_i64(anchor),
1470            };
1471            let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1472                .map_err(|_| {
1473                    DocumentError::InvalidCursorContext(
1474                        "cursor anchor is not inside a table".into(),
1475                    )
1476                })?;
1477            let block = crate::text_block::TextBlock {
1478                doc: self.doc.clone(),
1479                block_id: block_info.block_id as usize,
1480            };
1481            drop(inner);
1482            block.table_cell().ok_or_else(|| {
1483                DocumentError::InvalidCursorContext("cursor anchor is not inside a table".into())
1484            })?
1485        };
1486
1487        if pos_cell.table.id() != anchor_cell.table.id() {
1488            return Err(DocumentError::InvalidArgument(
1489                "position and anchor are in different tables".into(),
1490            ));
1491        }
1492
1493        let start_row = pos_cell.row.min(anchor_cell.row);
1494        let start_col = pos_cell.column.min(anchor_cell.column);
1495        let end_row = pos_cell.row.max(anchor_cell.row);
1496        let end_col = pos_cell.column.max(anchor_cell.column);
1497
1498        self.merge_table_cells(pos_cell.table.id(), start_row, start_col, end_row, end_col)
1499    }
1500
1501    /// Split the cell at the cursor's current position.
1502    /// Returns an error if the cursor is not inside a table.
1503    pub fn split_current_cell(&self, split_rows: usize, split_columns: usize) -> Result<()> {
1504        let cell_ref = self.current_table_cell().ok_or_else(|| {
1505            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1506        })?;
1507        // Get the cell entity ID from the table handle
1508        let cell = cell_ref
1509            .table
1510            .cell(cell_ref.row, cell_ref.column)
1511            .ok_or_else(|| DocumentError::NotFound("cell not found".into()))?;
1512        // TextTableCell stores cell_id
1513        self.split_table_cell(cell.id(), split_rows, split_columns)
1514    }
1515
1516    /// Set formatting on the table the cursor is currently inside.
1517    /// Returns an error if the cursor is not inside a table.
1518    pub fn set_current_table_format(&self, format: &crate::flow::TableFormat) -> Result<()> {
1519        let table = self.current_table().ok_or_else(|| {
1520            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1521        })?;
1522        self.set_table_format(table.id(), format)
1523    }
1524
1525    /// Set formatting on the cell the cursor is currently inside.
1526    /// Returns an error if the cursor is not inside a table.
1527    pub fn set_current_cell_format(&self, format: &crate::flow::CellFormat) -> Result<()> {
1528        let cell_ref = self.current_table_cell().ok_or_else(|| {
1529            DocumentError::InvalidCursorContext("cursor is not inside a table".into())
1530        })?;
1531        let cell = cell_ref
1532            .table
1533            .cell(cell_ref.row, cell_ref.column)
1534            .ok_or_else(|| DocumentError::NotFound("cell not found".into()))?;
1535        self.set_table_cell_format(cell.id(), format)
1536    }
1537
1538    // ── Cell selection queries ────────────────────────────────
1539
1540    /// Determine the kind of selection the cursor currently has.
1541    ///
1542    /// Returns [`Cells`](crate::SelectionKind::Cells) when position and anchor are in
1543    /// different cells of the same table (rectangular cell selection), or
1544    /// when an explicit cell-selection override is active.
1545    pub fn selection_kind(&self) -> crate::flow::SelectionKind {
1546        use crate::flow::{CellRange, SelectionKind};
1547
1548        // Check override first
1549        {
1550            let d = self.data.lock();
1551            if let Some(ref range) = d.cell_selection_override {
1552                return SelectionKind::Cells(range.clone());
1553            }
1554            if d.position == d.anchor {
1555                return SelectionKind::None;
1556            }
1557        }
1558
1559        let (pos, anchor) = self.read_cursor();
1560
1561        // Look up table cell for position and anchor
1562        let pos_cell = self.table_cell_at(pos);
1563        let anchor_cell = self.table_cell_at(anchor);
1564
1565        match (&pos_cell, &anchor_cell) {
1566            (None, None) => {
1567                // Both endpoints are outside tables. Check whether a table
1568                // sits between them — if so, all its cells must be selected
1569                // (Word behaviour).
1570                let (start, end) = (pos.min(anchor), pos.max(anchor));
1571                if let Some(t) = self.find_table_between(start, end) {
1572                    let table_id = t.id();
1573                    let rows = t.rows();
1574                    let cols = t.columns();
1575                    let range = CellRange {
1576                        table_id,
1577                        start_row: 0,
1578                        start_col: 0,
1579                        end_row: if rows > 0 { rows - 1 } else { 0 },
1580                        end_col: if cols > 0 { cols - 1 } else { 0 },
1581                    };
1582                    let spans = self.collect_cell_spans(table_id);
1583                    SelectionKind::Mixed {
1584                        cell_range: range.expand_for_spans(&spans),
1585                        text_before: true,
1586                        text_after: true,
1587                    }
1588                } else {
1589                    SelectionKind::Text
1590                }
1591            }
1592            (Some(pc), Some(ac)) => {
1593                if pc.table.id() != ac.table.id() {
1594                    // Different tables — treat as text (whole tables selected between them)
1595                    return SelectionKind::Text;
1596                }
1597                if pc.row == ac.row && pc.column == ac.column {
1598                    // Same cell — text selection within one cell
1599                    return SelectionKind::Text;
1600                }
1601                // Different cells, same table — rectangular cell selection
1602                let range = CellRange {
1603                    table_id: pc.table.id(),
1604                    start_row: pc.row.min(ac.row),
1605                    start_col: pc.column.min(ac.column),
1606                    end_row: pc.row.max(ac.row),
1607                    end_col: pc.column.max(ac.column),
1608                };
1609                let spans = self.collect_cell_spans(pc.table.id());
1610                SelectionKind::Cells(range.expand_for_spans(&spans))
1611            }
1612            (Some(tc), None) | (None, Some(tc)) => {
1613                // One endpoint inside a table, the other outside — mixed
1614                // selection.  Following Word behaviour, select ALL cells in
1615                // the table (not just from the entry edge to the cursor row).
1616                let table_id = tc.table.id();
1617                let rows = tc.table.rows();
1618                let cols = tc.table.columns();
1619
1620                let inside_pos = if pos_cell.is_some() { pos } else { anchor };
1621                let outside_pos = if pos_cell.is_some() { anchor } else { pos };
1622
1623                let text_before = outside_pos < inside_pos;
1624                let text_after = !text_before;
1625
1626                let range = CellRange {
1627                    table_id,
1628                    start_row: 0,
1629                    start_col: 0,
1630                    end_row: if rows > 0 { rows - 1 } else { 0 },
1631                    end_col: if cols > 0 { cols - 1 } else { 0 },
1632                };
1633                let spans = self.collect_cell_spans(table_id);
1634                SelectionKind::Mixed {
1635                    cell_range: range.expand_for_spans(&spans),
1636                    text_before,
1637                    text_after,
1638                }
1639            }
1640        }
1641    }
1642
1643    /// Returns `true` when the current selection involves whole-cell selection.
1644    pub fn is_cell_selection(&self) -> bool {
1645        matches!(
1646            self.selection_kind(),
1647            crate::flow::SelectionKind::Cells(_) | crate::flow::SelectionKind::Mixed { .. }
1648        )
1649    }
1650
1651    /// Returns the rectangular cell range if the cursor has a cell selection.
1652    pub fn selected_cell_range(&self) -> Option<crate::flow::CellRange> {
1653        match self.selection_kind() {
1654            crate::flow::SelectionKind::Cells(r) => Some(r),
1655            crate::flow::SelectionKind::Mixed { cell_range, .. } => Some(cell_range),
1656            _ => None,
1657        }
1658    }
1659
1660    /// Returns all cells in the selected rectangular range.
1661    pub fn selected_cells(&self) -> Vec<TableCellRef> {
1662        let range = match self.selected_cell_range() {
1663            Some(r) => r,
1664            None => return Vec::new(),
1665        };
1666        let table = TextTable {
1667            doc: self.doc.clone(),
1668            table_id: range.table_id,
1669        };
1670        let mut cells = Vec::new();
1671        for row in range.start_row..=range.end_row {
1672            for col in range.start_col..=range.end_col {
1673                if table.cell(row, col).is_some() {
1674                    cells.push(TableCellRef {
1675                        table: table.clone(),
1676                        row,
1677                        column: col,
1678                    });
1679                }
1680            }
1681        }
1682        cells
1683    }
1684
1685    // ── Explicit cell selection ─────────────────────────────
1686
1687    /// Set an explicit single-cell selection override.
1688    pub fn select_table_cell(&self, table_id: usize, row: usize, col: usize) {
1689        let mut d = self.data.lock();
1690        d.cell_selection_override = Some(crate::flow::CellRange {
1691            table_id,
1692            start_row: row,
1693            start_col: col,
1694            end_row: row,
1695            end_col: col,
1696        });
1697    }
1698
1699    /// Set an explicit rectangular cell-range selection override.
1700    pub fn select_cell_range(
1701        &self,
1702        table_id: usize,
1703        start_row: usize,
1704        start_col: usize,
1705        end_row: usize,
1706        end_col: usize,
1707    ) {
1708        let range = crate::flow::CellRange {
1709            table_id,
1710            start_row,
1711            start_col,
1712            end_row,
1713            end_col,
1714        };
1715        let spans = self.collect_cell_spans(table_id);
1716        let mut d = self.data.lock();
1717        d.cell_selection_override = Some(range.expand_for_spans(&spans));
1718    }
1719
1720    /// Clear any cell-selection override without changing position/anchor.
1721    pub fn clear_cell_selection(&self) {
1722        let mut d = self.data.lock();
1723        d.cell_selection_override = None;
1724    }
1725
1726    /// Compute (min_position, max_position) spanning all blocks in a cell range.
1727    /// Returns `None` if the table or cells cannot be found.
1728    fn cell_range_positions(&self, range: &CellRange) -> Option<(usize, usize)> {
1729        let inner = self.doc.lock();
1730        let main_frame_id = get_main_frame_id(&inner);
1731        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1732        drop(inner);
1733
1734        // Find the table matching the range's table_id
1735        let table = flow.into_iter().find_map(|e| match e {
1736            FlowElement::Table(t) if t.id() == range.table_id => Some(t),
1737            _ => None,
1738        })?;
1739
1740        let mut min_pos = usize::MAX;
1741        let mut max_pos = 0usize;
1742
1743        for row in range.start_row..=range.end_row {
1744            for col in range.start_col..=range.end_col {
1745                if let Some(cell) = table.cell(row, col) {
1746                    for block in cell.blocks() {
1747                        let bp = block.position();
1748                        let bl = block.length();
1749                        min_pos = min_pos.min(bp);
1750                        max_pos = max_pos.max(bp + bl);
1751                    }
1752                }
1753            }
1754        }
1755
1756        if min_pos == usize::MAX {
1757            return None;
1758        }
1759
1760        // Extend max_pos past the last block to ensure cross-cell detection
1761        Some((min_pos, max_pos + 1))
1762    }
1763
1764    // ── Cell selection helpers (private) ─────────────────────
1765
1766    /// Look up which table cell contains the given document position, if any.
1767    fn table_cell_at(&self, position: usize) -> Option<TableCellRef> {
1768        let inner = self.doc.lock();
1769        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1770            position: to_i64(position),
1771        };
1772        let block_info =
1773            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1774
1775        let block_id = if to_i64(position) < block_info.block_start && position > 0 {
1776            let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
1777                position: to_i64(position - 1),
1778            };
1779            let prev_info =
1780                document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto).ok()?;
1781            prev_info.block_id as usize
1782        } else {
1783            block_info.block_id as usize
1784        };
1785
1786        let block = crate::text_block::TextBlock {
1787            doc: self.doc.clone(),
1788            block_id,
1789        };
1790        drop(inner);
1791        block.table_cell()
1792    }
1793
1794    /// Find the document position at the boundary of the block adjacent to a
1795    /// table. Used by the table-trap logic in [`set_position`](Self::set_position).
1796    ///
1797    /// - `before == true`: returns the last position of the block immediately
1798    ///   before the table (i.e. `block.position() + block.length()`).
1799    /// - `before == false`: returns the first position of the block immediately
1800    ///   after the table.
1801    ///
1802    /// Returns `None` when no adjacent block exists (table is first or last
1803    /// element in the flow).
1804    fn table_boundary_position(&self, table_id: usize, before: bool) -> Option<usize> {
1805        let inner = self.doc.lock();
1806        let main_frame_id = get_main_frame_id(&inner);
1807        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1808        drop(inner);
1809
1810        // Find the table in the flow and peek at the adjacent element.
1811        let idx = flow
1812            .iter()
1813            .position(|e| matches!(e, FlowElement::Table(t) if t.id() == table_id))?;
1814
1815        if before {
1816            // Walk backwards to find the nearest Block.
1817            for i in (0..idx).rev() {
1818                if let FlowElement::Block(b) = &flow[i] {
1819                    return Some(b.position() + b.length());
1820                }
1821            }
1822        } else {
1823            // Walk forwards to find the nearest Block.
1824            for item in flow.iter().skip(idx + 1) {
1825                if let FlowElement::Block(b) = item {
1826                    return Some(b.position());
1827                }
1828            }
1829        }
1830        None
1831    }
1832
1833    /// Find the first table whose cell blocks fall within the range `(start, end)`.
1834    fn find_table_between(&self, start: usize, end: usize) -> Option<TextTable> {
1835        let inner = self.doc.lock();
1836        let main_frame_id = get_main_frame_id(&inner);
1837        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1838        drop(inner);
1839
1840        for elem in flow {
1841            if let FlowElement::Table(t) = elem {
1842                // Check whether the first cell's block position is between
1843                // the two endpoints (i.e. the table is inside the range).
1844                if let Some(first_cell) = t.cell(0, 0) {
1845                    let blocks = first_cell.blocks();
1846                    if let Some(fb) = blocks.first() {
1847                        let p = fb.position();
1848                        if p > start && p < end {
1849                            return Some(t);
1850                        }
1851                    }
1852                }
1853            }
1854        }
1855        None
1856    }
1857
1858    /// Collect `(row, col, row_span, col_span)` tuples for all cells in a table.
1859    fn collect_cell_spans(&self, table_id: usize) -> Vec<(usize, usize, usize, usize)> {
1860        let inner = self.doc.lock();
1861        let table_dto =
1862            match frontend::commands::table_commands::get_table(&inner.ctx, &(table_id as u64))
1863                .ok()
1864                .flatten()
1865            {
1866                Some(t) => t,
1867                None => return Vec::new(),
1868            };
1869
1870        let mut spans = Vec::with_capacity(table_dto.cells.len());
1871        for &cell_id in &table_dto.cells {
1872            if let Some(cell) =
1873                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &cell_id)
1874                    .ok()
1875                    .flatten()
1876            {
1877                spans.push((
1878                    cell.row as usize,
1879                    cell.column as usize,
1880                    cell.row_span.max(1) as usize,
1881                    cell.column_span.max(1) as usize,
1882                ));
1883            }
1884        }
1885        spans
1886    }
1887
1888    /// Delete the character after the cursor (Delete key).
1889    pub fn delete_char(&self) -> Result<()> {
1890        let (pos, anchor) = self.read_cursor();
1891        let (del_pos, del_anchor) = if pos != anchor {
1892            (pos, anchor)
1893        } else {
1894            // No-op at end of document (symmetric with delete_previous_char at start)
1895            let end = {
1896                let inner = self.doc.lock();
1897                document_inspection_commands::get_document_stats(&inner.ctx)
1898                    .map(|s| max_cursor_position(&s))
1899                    .unwrap_or(0)
1900            };
1901            if pos >= end {
1902                return Ok(());
1903            }
1904            // Delete the whole grapheme cluster after the cursor so a
1905            // single Delete on `👋🏻` or `e\u{0301}` removes the
1906            // user-perceived character, not just its first scalar.
1907            let to = self.next_grapheme_boundary(pos);
1908            if to == pos {
1909                return Ok(());
1910            }
1911            (pos, to)
1912        };
1913        self.do_delete(del_pos, del_anchor)
1914    }
1915
1916    /// Delete the character before the cursor (Backspace key).
1917    pub fn delete_previous_char(&self) -> Result<()> {
1918        let (pos, anchor) = self.read_cursor();
1919        let (del_pos, del_anchor) = if pos != anchor {
1920            (pos, anchor)
1921        } else if pos > 0 {
1922            let from = self.prev_grapheme_boundary(pos);
1923            if from == pos {
1924                return Ok(());
1925            }
1926            (from, pos)
1927        } else {
1928            return Ok(());
1929        };
1930        self.do_delete(del_pos, del_anchor)
1931    }
1932
1933    /// Delete the selected text. Returns the deleted text. No-op if no selection.
1934    pub fn remove_selected_text(&self) -> Result<String> {
1935        let (pos, anchor) = self.read_cursor();
1936        if pos == anchor {
1937            return Ok(String::new());
1938        }
1939        let queued = {
1940            let mut inner = self.doc.lock();
1941            let dto = frontend::document_editing::DeleteTextDto {
1942                position: to_i64(pos),
1943                anchor: to_i64(anchor),
1944            };
1945            let result =
1946                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1947            let edit_pos = pos.min(anchor);
1948            let removed = pos.max(anchor) - edit_pos;
1949            let new_pos = to_usize(result.new_position);
1950            inner.adjust_cursors(edit_pos, removed, 0);
1951            {
1952                let mut d = self.data.lock();
1953                d.position = new_pos;
1954                d.anchor = new_pos;
1955            }
1956            inner.modified = true;
1957            inner.invalidate_text_cache();
1958            inner.rehighlight_affected(edit_pos);
1959            inner.queue_event(DocumentEvent::ContentsChanged {
1960                position: edit_pos,
1961                chars_removed: removed,
1962                chars_added: 0,
1963                blocks_affected: 1,
1964            });
1965            inner.check_block_count_changed();
1966            inner.check_flow_changed();
1967            // Return the deleted text alongside the queued events
1968            (result.deleted_text, self.queue_undo_redo_event(&mut inner))
1969        };
1970        crate::inner::dispatch_queued_events(queued.1);
1971        Ok(queued.0)
1972    }
1973
1974    // ── List operations ──────────────────────────────────────
1975
1976    /// Returns the list that the block at the cursor position belongs to,
1977    /// or `None` if the current block is not a list item.
1978    pub fn current_list(&self) -> Option<crate::TextList> {
1979        let pos = self.position();
1980        let inner = self.doc.lock();
1981        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1982            position: to_i64(pos),
1983        };
1984        let block_info =
1985            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1986        let block = crate::text_block::TextBlock {
1987            doc: self.doc.clone(),
1988            block_id: block_info.block_id as usize,
1989        };
1990        drop(inner);
1991        block.list()
1992    }
1993
1994    /// Turn the block(s) in the selection into a list.
1995    pub fn create_list(&self, style: ListStyle) -> Result<()> {
1996        let (pos, anchor) = self.read_cursor();
1997        let queued = {
1998            let mut inner = self.doc.lock();
1999            let dto = frontend::document_editing::CreateListDto {
2000                position: to_i64(pos),
2001                anchor: to_i64(anchor),
2002                style: style.clone(),
2003            };
2004            document_editing_commands::create_list(&inner.ctx, Some(inner.stack_id), &dto)?;
2005            inner.modified = true;
2006            inner.rehighlight_affected(pos.min(anchor));
2007            inner.queue_event(DocumentEvent::ContentsChanged {
2008                position: pos.min(anchor),
2009                chars_removed: 0,
2010                chars_added: 0,
2011                blocks_affected: 1,
2012            });
2013            self.queue_undo_redo_event(&mut inner)
2014        };
2015        crate::inner::dispatch_queued_events(queued);
2016        Ok(())
2017    }
2018
2019    /// Insert a new list item at the cursor position.
2020    pub fn insert_list(&self, style: ListStyle) -> Result<()> {
2021        let (pos, anchor) = self.read_cursor();
2022        let queued = {
2023            let mut inner = self.doc.lock();
2024            let dto = frontend::document_editing::InsertListDto {
2025                position: to_i64(pos),
2026                anchor: to_i64(anchor),
2027                style: style.clone(),
2028            };
2029            let result =
2030                document_editing_commands::insert_list(&inner.ctx, Some(inner.stack_id), &dto)?;
2031            let edit_pos = pos.min(anchor);
2032            let removed = pos.max(anchor) - edit_pos;
2033            self.finish_edit_ext(
2034                &mut inner,
2035                edit_pos,
2036                removed,
2037                to_usize(result.new_position),
2038                1,
2039                false,
2040            )
2041        };
2042        crate::inner::dispatch_queued_events(queued);
2043        Ok(())
2044    }
2045
2046    /// Set formatting on a list by its ID.
2047    pub fn set_list_format(&self, list_id: usize, format: &crate::ListFormat) -> Result<()> {
2048        let queued = {
2049            let mut inner = self.doc.lock();
2050            let dto = format.to_set_dto(list_id);
2051            document_formatting_commands::set_list_format(&inner.ctx, Some(inner.stack_id), &dto)?;
2052            inner.modified = true;
2053            inner.queue_event(DocumentEvent::FormatChanged {
2054                position: 0,
2055                length: 0,
2056                kind: crate::flow::FormatChangeKind::List,
2057            });
2058            self.queue_undo_redo_event(&mut inner)
2059        };
2060        crate::inner::dispatch_queued_events(queued);
2061        Ok(())
2062    }
2063
2064    /// Set formatting on the list that the current block belongs to.
2065    /// Returns an error if the cursor is not inside a list item.
2066    pub fn set_current_list_format(&self, format: &crate::ListFormat) -> Result<()> {
2067        let list = self.current_list().ok_or_else(|| {
2068            DocumentError::InvalidCursorContext("cursor is not inside a list".into())
2069        })?;
2070        self.set_list_format(list.id(), format)
2071    }
2072
2073    /// Add a block to a list by their IDs.
2074    pub fn add_block_to_list(&self, block_id: usize, list_id: usize) -> Result<()> {
2075        let queued = {
2076            let mut inner = self.doc.lock();
2077            let dto = frontend::document_editing::AddBlockToListDto {
2078                block_id: to_i64(block_id),
2079                list_id: to_i64(list_id),
2080            };
2081            document_editing_commands::add_block_to_list(&inner.ctx, Some(inner.stack_id), &dto)?;
2082            inner.modified = true;
2083            // List membership is a formatting/layout concern, not a text
2084            // change — fire FormatChanged so consumers re-layout (the
2085            // block's horizontal position and list marker depend on
2086            // its list assignment). ContentsChanged with position=0
2087            // was misleading and caused incremental relayouts to
2088            // re-shape the wrong block.
2089            inner.queue_event(DocumentEvent::FormatChanged {
2090                position: 0,
2091                length: 0,
2092                kind: crate::flow::FormatChangeKind::List,
2093            });
2094            self.queue_undo_redo_event(&mut inner)
2095        };
2096        crate::inner::dispatch_queued_events(queued);
2097        Ok(())
2098    }
2099
2100    /// Add the block at the cursor position to a list.
2101    pub fn add_current_block_to_list(&self, list_id: usize) -> Result<()> {
2102        let pos = self.position();
2103        let inner = self.doc.lock();
2104        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2105            position: to_i64(pos),
2106        };
2107        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
2108        drop(inner);
2109        self.add_block_to_list(block_info.block_id as usize, list_id)
2110    }
2111
2112    /// Remove a block from its list by block ID.
2113    pub fn remove_block_from_list(&self, block_id: usize) -> Result<()> {
2114        let queued = {
2115            let mut inner = self.doc.lock();
2116            let dto = frontend::document_editing::RemoveBlockFromListDto {
2117                block_id: to_i64(block_id),
2118            };
2119            document_editing_commands::remove_block_from_list(
2120                &inner.ctx,
2121                Some(inner.stack_id),
2122                &dto,
2123            )?;
2124            inner.modified = true;
2125            // See `add_block_to_list` — list-membership is a
2126            // formatting/layout change, not a text content change.
2127            inner.queue_event(DocumentEvent::FormatChanged {
2128                position: 0,
2129                length: 0,
2130                kind: crate::flow::FormatChangeKind::List,
2131            });
2132            self.queue_undo_redo_event(&mut inner)
2133        };
2134        crate::inner::dispatch_queued_events(queued);
2135        Ok(())
2136    }
2137
2138    /// Remove the block at the cursor position from its list.
2139    /// Returns an error if the current block is not a list item.
2140    pub fn remove_current_block_from_list(&self) -> Result<()> {
2141        let pos = self.position();
2142        let inner = self.doc.lock();
2143        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2144            position: to_i64(pos),
2145        };
2146        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
2147        drop(inner);
2148        self.remove_block_from_list(block_info.block_id as usize)
2149    }
2150
2151    /// Remove a list item by index within the list.
2152    /// Resolves the index to a block, then removes it from the list.
2153    pub fn remove_list_item(&self, list_id: usize, index: usize) -> Result<()> {
2154        let list = crate::text_list::TextList {
2155            doc: self.doc.clone(),
2156            list_id,
2157        };
2158        let block = list.item(index).ok_or_else(|| {
2159            DocumentError::OutOfRange(format!("list item index {index} out of range"))
2160        })?;
2161        self.remove_block_from_list(block.id())
2162    }
2163
2164    // ── Format queries ───────────────────────────────────────
2165
2166    /// Get the character format at the cursor position. Reads the
2167    /// covering `FormatRun` (or image anchor) directly from the store.
2168    pub fn char_format(&self) -> Result<TextFormat> {
2169        let pos = self.position();
2170        let inner = self.doc.lock();
2171
2172        // Locate the block containing the cursor.
2173        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2174            position: to_i64(pos),
2175        };
2176        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
2177        let block_id = block_info.block_id as u64;
2178        let mut block_dto =
2179            frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
2180                .ok_or_else(|| DocumentError::NotFound("block not found at position".into()))?;
2181        let store = inner.ctx.db_context.get_store();
2182        crate::inner::refresh_block_position(&mut block_dto, store);
2183
2184        // Convert document-wide char position to a byte offset within
2185        // the block's content (read from the rope).
2186        let local_char = pos.saturating_sub(block_dto.document_position as usize);
2187        let entity: common::entities::Block = block_dto.clone().into();
2188        let plain_owned = common::database::rope_helpers::block_content_via_store(&entity, store);
2189        let plain: &str = &plain_owned;
2190        let byte_offset: u32 = plain
2191            .char_indices()
2192            .nth(local_char)
2193            .map(|(b, _)| b as u32)
2194            .unwrap_or(plain.len() as u32);
2195
2196        // If there's an image anchor at this exact byte position, use
2197        // its format.
2198        let images = store
2199            .block_images
2200            .read()
2201            .get(&block_id)
2202            .cloned()
2203            .unwrap_or_default();
2204        if let Some(img) = images.iter().find(|i| i.byte_offset == byte_offset) {
2205            return Ok(TextFormat::from(&img.format));
2206        }
2207
2208        // Otherwise find the FormatRun covering the byte position.
2209        let runs = store
2210            .format_runs
2211            .read()
2212            .get(&block_id)
2213            .cloned()
2214            .unwrap_or_default();
2215        let fmt = runs
2216            .iter()
2217            .find(|r| r.byte_start <= byte_offset && byte_offset < r.byte_end)
2218            .map(|r| TextFormat::from(&r.format))
2219            .unwrap_or_default();
2220        Ok(fmt)
2221    }
2222
2223    /// Get the block format of the block containing the cursor.
2224    pub fn block_format(&self) -> Result<BlockFormat> {
2225        let pos = self.position();
2226        let inner = self.doc.lock();
2227        let dto = frontend::document_inspection::GetBlockAtPositionDto {
2228            position: to_i64(pos),
2229        };
2230        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
2231        let block_id = block_info.block_id as u64;
2232        let block = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
2233            .ok_or_else(|| DocumentError::NotFound("block not found".into()))?;
2234        Ok(BlockFormat::from(&block))
2235    }
2236
2237    // ── Format application ───────────────────────────────────
2238
2239    /// Set the character format for the selection.
2240    pub fn set_char_format(&self, format: &TextFormat) -> Result<()> {
2241        let (pos, anchor) = self.read_cursor();
2242        let queued = {
2243            let mut inner = self.doc.lock();
2244            let dto = format.to_set_dto(pos, anchor);
2245            document_formatting_commands::set_text_format(&inner.ctx, Some(inner.stack_id), &dto)?;
2246            let start = pos.min(anchor);
2247            let length = pos.max(anchor) - start;
2248            inner.modified = true;
2249            inner.queue_event(DocumentEvent::FormatChanged {
2250                position: start,
2251                length,
2252                kind: crate::flow::FormatChangeKind::Character,
2253            });
2254            self.queue_undo_redo_event(&mut inner)
2255        };
2256        crate::inner::dispatch_queued_events(queued);
2257        Ok(())
2258    }
2259
2260    /// Merge a character format into the selection.
2261    pub fn merge_char_format(&self, format: &TextFormat) -> Result<()> {
2262        let (pos, anchor) = self.read_cursor();
2263        let queued = {
2264            let mut inner = self.doc.lock();
2265            let dto = format.to_merge_dto(pos, anchor);
2266            document_formatting_commands::merge_text_format(
2267                &inner.ctx,
2268                Some(inner.stack_id),
2269                &dto,
2270            )?;
2271            let start = pos.min(anchor);
2272            let length = pos.max(anchor) - start;
2273            inner.modified = true;
2274            inner.queue_event(DocumentEvent::FormatChanged {
2275                position: start,
2276                length,
2277                kind: crate::flow::FormatChangeKind::Character,
2278            });
2279            self.queue_undo_redo_event(&mut inner)
2280        };
2281        crate::inner::dispatch_queued_events(queued);
2282        Ok(())
2283    }
2284
2285    /// Set the block format for the current block (or all blocks in selection).
2286    pub fn set_block_format(&self, format: &BlockFormat) -> Result<()> {
2287        let (pos, anchor) = self.read_cursor();
2288        let queued = {
2289            let mut inner = self.doc.lock();
2290            let dto = format.to_set_dto(pos, anchor);
2291            document_formatting_commands::set_block_format(&inner.ctx, Some(inner.stack_id), &dto)?;
2292            let start = pos.min(anchor);
2293            let length = pos.max(anchor) - start;
2294            inner.modified = true;
2295            inner.queue_event(DocumentEvent::FormatChanged {
2296                position: start,
2297                length,
2298                kind: crate::flow::FormatChangeKind::Block,
2299            });
2300            self.queue_undo_redo_event(&mut inner)
2301        };
2302        crate::inner::dispatch_queued_events(queued);
2303        Ok(())
2304    }
2305
2306    /// Set the frame format.
2307    pub fn set_frame_format(&self, frame_id: usize, format: &FrameFormat) -> Result<()> {
2308        let (pos, anchor) = self.read_cursor();
2309        let queued = {
2310            let mut inner = self.doc.lock();
2311            let dto = format.to_set_dto(pos, anchor, frame_id);
2312            document_formatting_commands::set_frame_format(&inner.ctx, Some(inner.stack_id), &dto)?;
2313            let start = pos.min(anchor);
2314            let length = pos.max(anchor) - start;
2315            inner.modified = true;
2316            inner.queue_event(DocumentEvent::FormatChanged {
2317                position: start,
2318                length,
2319                kind: crate::flow::FormatChangeKind::Block,
2320            });
2321            self.queue_undo_redo_event(&mut inner)
2322        };
2323        crate::inner::dispatch_queued_events(queued);
2324        Ok(())
2325    }
2326
2327    // ── Edit blocks (composite undo) ─────────────────────────
2328
2329    /// Begin a group of operations that will be undone as a single unit.
2330    pub fn begin_edit_block(&self) {
2331        let inner = self.doc.lock();
2332        undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
2333    }
2334
2335    /// End the current edit block.
2336    pub fn end_edit_block(&self) {
2337        let inner = self.doc.lock();
2338        undo_redo_commands::end_composite(&inner.ctx);
2339    }
2340
2341    /// Alias for [`begin_edit_block`](Self::begin_edit_block).
2342    ///
2343    /// Semantically indicates that the new composite should be merged with
2344    /// the previous one (e.g., consecutive keystrokes grouped into a single
2345    /// undo unit). The current backend treats this identically to
2346    /// `begin_edit_block`; future versions may implement automatic merging.
2347    pub fn join_previous_edit_block(&self) {
2348        self.begin_edit_block();
2349    }
2350
2351    // ── Private helpers ─────────────────────────────────────
2352
2353    /// Queue an `UndoRedoChanged` event and return all queued events for dispatch.
2354    fn queue_undo_redo_event(&self, inner: &mut TextDocumentInner) -> QueuedEvents {
2355        let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
2356        let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
2357        inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
2358        inner.take_queued_events()
2359    }
2360
2361    fn do_delete(&self, pos: usize, anchor: usize) -> Result<()> {
2362        let queued = {
2363            let mut inner = self.doc.lock();
2364            let dto = frontend::document_editing::DeleteTextDto {
2365                position: to_i64(pos),
2366                anchor: to_i64(anchor),
2367            };
2368            let result =
2369                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
2370            let edit_pos = pos.min(anchor);
2371            let removed = pos.max(anchor) - edit_pos;
2372            let new_pos = to_usize(result.new_position);
2373            inner.adjust_cursors(edit_pos, removed, 0);
2374            {
2375                let mut d = self.data.lock();
2376                d.position = new_pos;
2377                d.anchor = new_pos;
2378            }
2379            inner.modified = true;
2380            inner.invalidate_text_cache();
2381            inner.rehighlight_affected(edit_pos);
2382            inner.queue_event(DocumentEvent::ContentsChanged {
2383                position: edit_pos,
2384                chars_removed: removed,
2385                chars_added: 0,
2386                blocks_affected: 1,
2387            });
2388            inner.check_block_count_changed();
2389            inner.check_flow_changed();
2390            self.queue_undo_redo_event(&mut inner)
2391        };
2392        crate::inner::dispatch_queued_events(queued);
2393        Ok(())
2394    }
2395
2396    /// Resolve a MoveOperation to a concrete position.
2397    fn resolve_move(&self, op: MoveOperation, n: usize) -> usize {
2398        let pos = self.position();
2399        match op {
2400            MoveOperation::NoMove => pos,
2401            MoveOperation::Start => 0,
2402            MoveOperation::End => {
2403                let inner = self.doc.lock();
2404                document_inspection_commands::get_document_stats(&inner.ctx)
2405                    .map(|s| max_cursor_position(&s))
2406                    .unwrap_or(pos)
2407            }
2408            MoveOperation::NextCharacter | MoveOperation::Right => {
2409                let mut cur = pos;
2410                for _ in 0..n {
2411                    let next = self.next_grapheme_boundary(cur);
2412                    if next == cur {
2413                        break;
2414                    }
2415                    cur = next;
2416                }
2417                cur
2418            }
2419            MoveOperation::PreviousCharacter | MoveOperation::Left => {
2420                let mut cur = pos;
2421                for _ in 0..n {
2422                    let prev = self.prev_grapheme_boundary(cur);
2423                    if prev == cur {
2424                        break;
2425                    }
2426                    cur = prev;
2427                }
2428                cur
2429            }
2430            MoveOperation::StartOfBlock | MoveOperation::StartOfLine => {
2431                let inner = self.doc.lock();
2432                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2433                    position: to_i64(pos),
2434                };
2435                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2436                    .map(|info| to_usize(info.block_start))
2437                    .unwrap_or(pos)
2438            }
2439            MoveOperation::EndOfBlock | MoveOperation::EndOfLine => {
2440                let inner = self.doc.lock();
2441                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2442                    position: to_i64(pos),
2443                };
2444                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2445                    .map(|info| to_usize(info.block_start) + to_usize(info.block_length))
2446                    .unwrap_or(pos)
2447            }
2448            MoveOperation::NextBlock => {
2449                let inner = self.doc.lock();
2450                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2451                    position: to_i64(pos),
2452                };
2453                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2454                    .map(|info| {
2455                        // Move past current block + 1 (block separator)
2456                        to_usize(info.block_start) + to_usize(info.block_length) + 1
2457                    })
2458                    .unwrap_or(pos)
2459            }
2460            MoveOperation::PreviousBlock => {
2461                let inner = self.doc.lock();
2462                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2463                    position: to_i64(pos),
2464                };
2465                let block_start =
2466                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2467                        .map(|info| to_usize(info.block_start))
2468                        .unwrap_or(pos);
2469                if block_start >= 2 {
2470                    // Skip past the block separator (which maps to the current block)
2471                    let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
2472                        position: to_i64(block_start - 2),
2473                    };
2474                    document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto)
2475                        .map(|info| to_usize(info.block_start))
2476                        .unwrap_or(0)
2477                } else {
2478                    0
2479                }
2480            }
2481            MoveOperation::NextWord | MoveOperation::EndOfWord | MoveOperation::WordRight => {
2482                let (_, end) = self.find_word_boundaries(pos);
2483                // Move past the word end to the next word
2484                if end == pos {
2485                    // Already at a boundary, skip whitespace
2486                    let inner = self.doc.lock();
2487                    let max_pos = document_inspection_commands::get_document_stats(&inner.ctx)
2488                        .map(|s| max_cursor_position(&s))
2489                        .unwrap_or(0);
2490                    let scan_len = max_pos.saturating_sub(pos).min(64);
2491                    if scan_len == 0 {
2492                        return pos;
2493                    }
2494                    let dto = frontend::document_inspection::GetTextAtPositionDto {
2495                        position: to_i64(pos),
2496                        length: to_i64(scan_len),
2497                    };
2498                    if let Ok(r) =
2499                        document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
2500                    {
2501                        for (i, ch) in r.text.chars().enumerate() {
2502                            if ch.is_alphanumeric() || ch == '_' {
2503                                // Found start of next word, find its end
2504                                let word_pos = pos + i;
2505                                drop(inner);
2506                                let (_, word_end) = self.find_word_boundaries(word_pos);
2507                                return word_end;
2508                            }
2509                        }
2510                    }
2511                    pos + scan_len
2512                } else {
2513                    end
2514                }
2515            }
2516            MoveOperation::PreviousWord | MoveOperation::StartOfWord | MoveOperation::WordLeft => {
2517                let (start, _) = self.find_word_boundaries(pos);
2518                if start < pos {
2519                    start
2520                } else if pos > 0 {
2521                    // Cursor is at a word start or on whitespace — scan backwards
2522                    // to find the start of the previous word.
2523                    let mut search = pos - 1;
2524                    loop {
2525                        let (ws, we) = self.find_word_boundaries(search);
2526                        if ws < we {
2527                            // Found a word; return its start
2528                            break ws;
2529                        }
2530                        // Still on whitespace/non-word; keep scanning
2531                        if search == 0 {
2532                            break 0;
2533                        }
2534                        search -= 1;
2535                    }
2536                } else {
2537                    0
2538                }
2539            }
2540            MoveOperation::Up | MoveOperation::Down => {
2541                // Up/Down are visual operations that depend on line wrapping.
2542                // Without layout info, treat as PreviousBlock/NextBlock.
2543                if matches!(op, MoveOperation::Up) {
2544                    self.resolve_move(MoveOperation::PreviousBlock, 1)
2545                } else {
2546                    self.resolve_move(MoveOperation::NextBlock, 1)
2547                }
2548            }
2549        }
2550    }
2551
2552    /// Snap the cursor's current position to the nearest grapheme
2553    /// cluster boundary, moving forward if currently mid-cluster.
2554    /// No-op when already at a boundary.
2555    ///
2556    /// Applied automatically by `cursor_at` and `set_position` so a
2557    /// caller passing an arbitrary scalar index never lands inside a
2558    /// cluster — without this, a round-trip such as
2559    /// `NextCharacter → PreviousCharacter` would stop at the cluster
2560    /// start rather than the start position, because the pre-advance
2561    /// state wasn't a boundary to begin with.
2562    pub(crate) fn snap_position_to_grapheme_boundary(&self) {
2563        let pos = {
2564            let data = self.data.lock();
2565            data.position
2566        };
2567        let snapped = self.forward_grapheme_boundary_at_or_after(pos);
2568        if snapped != pos {
2569            let mut data = self.data.lock();
2570            data.position = snapped;
2571            if data.anchor == pos {
2572                data.anchor = snapped;
2573            }
2574        }
2575    }
2576
2577    /// Return `pos` if it sits at a grapheme cluster boundary within
2578    /// its block; otherwise return the end position of the containing
2579    /// cluster (snap forward). Block separators are always treated as
2580    /// boundaries.
2581    ///
2582    /// Leaves out-of-range positions (`pos > max_cursor_position`)
2583    /// unchanged — the snap must never silently upgrade an out-of-
2584    /// range cursor to a valid one, because edit ops rely on the
2585    /// out-of-range check to stay no-ops.
2586    fn forward_grapheme_boundary_at_or_after(&self, pos: usize) -> usize {
2587        let inner = self.doc.lock();
2588        let end = document_inspection_commands::get_document_stats(&inner.ctx)
2589            .map(|s| max_cursor_position(&s))
2590            .unwrap_or(pos);
2591        if pos >= end {
2592            return pos;
2593        }
2594        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2595            position: to_i64(pos),
2596        };
2597        let Ok(block_info) =
2598            document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto)
2599        else {
2600            return pos;
2601        };
2602        let block_start = to_usize(block_info.block_start);
2603        let block_length = to_usize(block_info.block_length);
2604        let offset_in_block = pos.saturating_sub(block_start);
2605        // Block boundaries (start / end) are always cluster boundaries.
2606        if offset_in_block == 0 || offset_in_block >= block_length {
2607            return pos;
2608        }
2609        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
2610            position: to_i64(block_start),
2611            length: to_i64(block_length),
2612        };
2613        let Ok(r) = document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto)
2614        else {
2615            return pos;
2616        };
2617        let text = r.text;
2618        drop(inner);
2619        // Walk grapheme clusters, accumulating char counts. The first
2620        // boundary >= offset_in_block is the snap target.
2621        let mut acc = 0usize;
2622        for g in text.graphemes(true) {
2623            if acc >= offset_in_block {
2624                return block_start + acc;
2625            }
2626            acc += g.chars().count();
2627        }
2628        block_start + acc
2629    }
2630
2631    /// Return the cursor position after advancing one extended grapheme
2632    /// cluster from `pos`. A grapheme cluster is what a user perceives
2633    /// as a single character — decomposed accents (`e` + `U+0301`),
2634    /// skin-tone emoji, ZWJ sequences, and regional-indicator flags
2635    /// are all single clusters even though they contain multiple
2636    /// Unicode scalars.
2637    ///
2638    /// Block separators (the single scalar between blocks in the
2639    /// cursor-position space) are treated as their own unit: advancing
2640    /// from the end of a block goes to the start of the next block
2641    /// (one scalar forward) without touching the grapheme path.
2642    /// Returns `pos` unchanged when already at the document end.
2643    fn next_grapheme_boundary(&self, pos: usize) -> usize {
2644        let inner = self.doc.lock();
2645        let end = document_inspection_commands::get_document_stats(&inner.ctx)
2646            .map(|s| max_cursor_position(&s))
2647            .unwrap_or(pos);
2648        if pos >= end {
2649            return pos;
2650        }
2651        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2652            position: to_i64(pos),
2653        };
2654        let block_info =
2655            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
2656                Ok(info) => info,
2657                Err(_) => return pos + 1,
2658            };
2659        let block_start = to_usize(block_info.block_start);
2660        let block_length = to_usize(block_info.block_length);
2661        let offset_in_block = pos.saturating_sub(block_start);
2662        if offset_in_block >= block_length {
2663            // At block end — advance across the separator into the
2664            // next block.
2665            return (pos + 1).min(end);
2666        }
2667        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
2668            position: to_i64(pos),
2669            length: to_i64(block_length - offset_in_block),
2670        };
2671        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto) {
2672            Ok(r) => r.text,
2673            Err(_) => return pos + 1,
2674        };
2675        drop(inner);
2676        match text.graphemes(true).next() {
2677            Some(g) if !g.is_empty() => (pos + g.chars().count()).min(end),
2678            _ => (pos + 1).min(end),
2679        }
2680    }
2681
2682    /// Return the cursor position before the extended grapheme cluster
2683    /// that ends at `pos`. Counterpart to [`Self::next_grapheme_boundary`].
2684    /// Crosses block separators one scalar at a time.
2685    fn prev_grapheme_boundary(&self, pos: usize) -> usize {
2686        if pos == 0 {
2687            return 0;
2688        }
2689        let inner = self.doc.lock();
2690        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2691            position: to_i64(pos.saturating_sub(1)),
2692        };
2693        let block_info =
2694            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
2695                Ok(info) => info,
2696                Err(_) => return pos - 1,
2697            };
2698        let block_start = to_usize(block_info.block_start);
2699        let block_length = to_usize(block_info.block_length);
2700        let block_end = block_start + block_length;
2701        // If `pos` sits past the block text (on a separator), step back
2702        // one scalar rather than running grapheme analysis across a
2703        // boundary.
2704        if pos > block_end {
2705            return pos - 1;
2706        }
2707        if block_length == 0 || pos <= block_start {
2708            return pos.saturating_sub(1);
2709        }
2710        let scan_len = pos - block_start;
2711        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
2712            position: to_i64(block_start),
2713            length: to_i64(scan_len),
2714        };
2715        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto) {
2716            Ok(r) => r.text,
2717            Err(_) => return pos - 1,
2718        };
2719        drop(inner);
2720        match text.graphemes(true).next_back() {
2721            Some(g) if !g.is_empty() => pos - g.chars().count(),
2722            _ => pos - 1,
2723        }
2724    }
2725
2726    /// Find the word boundaries around `pos`. Returns (start, end).
2727    /// Uses Unicode word segmentation for correct handling of non-ASCII text.
2728    ///
2729    /// Single-pass: tracks the last word seen to avoid a second iteration
2730    /// when the cursor is at the end of the last word (ISSUE-18).
2731    fn find_word_boundaries(&self, pos: usize) -> (usize, usize) {
2732        let inner = self.doc.lock();
2733        // Get block info so we can fetch the full block text
2734        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2735            position: to_i64(pos),
2736        };
2737        let block_info =
2738            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
2739                Ok(info) => info,
2740                Err(_) => return (pos, pos),
2741            };
2742
2743        let block_start = to_usize(block_info.block_start);
2744        let block_length = to_usize(block_info.block_length);
2745        if block_length == 0 {
2746            return (pos, pos);
2747        }
2748
2749        let dto = frontend::document_inspection::GetTextAtPositionDto {
2750            position: to_i64(block_start),
2751            length: to_i64(block_length),
2752        };
2753        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &dto) {
2754            Ok(r) => r.text,
2755            Err(_) => return (pos, pos),
2756        };
2757
2758        // cursor_offset is the char offset within the block text
2759        let cursor_offset = pos.saturating_sub(block_start);
2760
2761        // Single pass: track the last word seen for end-of-last-word check
2762        let mut last_char_start = 0;
2763        let mut last_char_end = 0;
2764
2765        for (word_byte_start, word) in text.unicode_word_indices() {
2766            // Convert byte offset to char offset
2767            let word_char_start = text[..word_byte_start].chars().count();
2768            let word_char_len = word.chars().count();
2769            let word_char_end = word_char_start + word_char_len;
2770
2771            last_char_start = word_char_start;
2772            last_char_end = word_char_end;
2773
2774            if cursor_offset >= word_char_start && cursor_offset < word_char_end {
2775                return (block_start + word_char_start, block_start + word_char_end);
2776            }
2777        }
2778
2779        // Check if cursor is exactly at the end of the last word
2780        if cursor_offset == last_char_end && last_char_start < last_char_end {
2781            return (block_start + last_char_start, block_start + last_char_end);
2782        }
2783
2784        (pos, pos)
2785    }
2786}
2787
2788// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2789// Frame-awareness helpers used by the public Cursor methods above.
2790// Each takes the locked TextDocumentInner so callers can reuse one
2791// store snapshot.
2792// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2793
2794#[derive(Clone, Copy, PartialEq, Eq)]
2795enum BlockEdge {
2796    First,
2797    Middle,
2798    Last,
2799    OnlyOne,
2800}
2801
2802/// Build a `FrameRef` for the innermost non-root frame containing
2803/// `block_id`. Returns `None` if the block sits directly in the root
2804/// frame (i.e. the only enclosing frame is the root).
2805fn cursor_frame_ref(inner: &TextDocumentInner, block_id: u64) -> Option<FrameRef> {
2806    let parent = crate::text_block::find_parent_frame(inner, block_id)?;
2807    let store = inner.ctx.db_context.get_store();
2808    let frames = store.frames.read();
2809    let frame = frames.get(&parent)?.clone();
2810    frame.parent_frame?;
2811    let is_blockquote = frame.fmt_is_blockquote.unwrap_or(false);
2812
2813    let mut depth = 0;
2814    let mut current = Some(parent);
2815    while let Some(id) = current {
2816        let Some(f) = frames.get(&id) else {
2817            break;
2818        };
2819        if f.parent_frame.is_none() {
2820            break;
2821        }
2822        depth += 1;
2823        current = f.parent_frame;
2824    }
2825
2826    Some(FrameRef {
2827        frame_id: frame.id as usize,
2828        parent_frame_id: frame.parent_frame.map(|id| id as usize),
2829        is_blockquote,
2830        depth,
2831    })
2832}
2833
2834/// Walk up the parent_frame chain from the block's immediate parent and
2835/// return the first blockquote frame found (innermost). `None` if no
2836/// enclosing frame is a blockquote.
2837fn innermost_blockquote_frame_id(inner: &TextDocumentInner, block_id: u64) -> Option<usize> {
2838    let mut current = crate::text_block::find_parent_frame(inner, block_id);
2839    let store = inner.ctx.db_context.get_store();
2840    let frames = store.frames.read();
2841    while let Some(id) = current {
2842        let f = frames.get(&id)?;
2843        if f.fmt_is_blockquote == Some(true) {
2844            return Some(f.id as usize);
2845        }
2846        current = f.parent_frame;
2847    }
2848    None
2849}
2850
2851/// Count how many blockquote frames sit on the parent_frame chain above
2852/// `block_id`. 0 if no enclosing frame is a blockquote.
2853fn blockquote_depth_for_block(inner: &TextDocumentInner, block_id: u64) -> usize {
2854    let mut current = crate::text_block::find_parent_frame(inner, block_id);
2855    let store = inner.ctx.db_context.get_store();
2856    let frames = store.frames.read();
2857    let mut count = 0;
2858    while let Some(id) = current {
2859        let Some(f) = frames.get(&id) else {
2860            break;
2861        };
2862        if f.fmt_is_blockquote == Some(true) {
2863            count += 1;
2864        }
2865        current = f.parent_frame;
2866    }
2867    count
2868}
2869
2870/// Resolve the cursor's block, find its immediate parent frame, and
2871/// determine the block's edge position within that frame's `child_order`
2872/// (counting only positive entries — sub-frames are skipped because they
2873/// are structurally different elements). Returns `None` if the cursor's
2874/// block has no entry in any frame's `child_order`.
2875fn block_position_in_current_frame(cursor: &TextCursor) -> Option<BlockEdge> {
2876    let pos = cursor.position();
2877    let inner = cursor.doc.lock();
2878    let dto = frontend::document_inspection::GetBlockAtPositionDto {
2879        position: to_i64(pos),
2880    };
2881    let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
2882    let block_id = block_info.block_id as common::types::EntityId;
2883    let parent_id = crate::text_block::find_parent_frame(&inner, block_info.block_id as u64)?;
2884    let store = inner.ctx.db_context.get_store();
2885    let frames = store.frames.read();
2886    let frame = frames.get(&parent_id)?;
2887    let block_positions: Vec<usize> = frame
2888        .child_order
2889        .iter()
2890        .enumerate()
2891        .filter_map(|(i, &e)| {
2892            if e > 0 {
2893                Some((i, e as common::types::EntityId))
2894            } else {
2895                None
2896            }
2897        })
2898        .filter(|(_, id)| *id == block_id)
2899        .map(|(i, _)| i)
2900        .collect();
2901    let block_idx = *block_positions.first()?;
2902    let positive_entries: Vec<usize> = frame
2903        .child_order
2904        .iter()
2905        .enumerate()
2906        .filter_map(|(i, &e)| if e > 0 { Some(i) } else { None })
2907        .collect();
2908    let first_pos = *positive_entries.first()?;
2909    let last_pos = *positive_entries.last()?;
2910    let is_first = block_idx == first_pos;
2911    let is_last = block_idx == last_pos;
2912    let edge = match (is_first, is_last, positive_entries.len()) {
2913        (_, _, 1) => BlockEdge::OnlyOne,
2914        (true, _, _) => BlockEdge::First,
2915        (_, true, _) => BlockEdge::Last,
2916        _ => BlockEdge::Middle,
2917    };
2918    Some(edge)
2919}