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