Skip to main content

text_document/
cursor.rs

1//! TextCursor implementation — Qt-style multi-cursor with automatic position adjustment.
2
3use std::sync::Arc;
4
5use parking_lot::Mutex;
6
7use anyhow::Result;
8
9use crate::ListStyle;
10use frontend::commands::{
11    document_editing_commands, document_formatting_commands, document_inspection_commands,
12    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, 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),
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),
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    // ── Table structure mutations (explicit-ID) ──────────
859
860    /// Remove a table from the document by its ID.
861    pub fn remove_table(&self, table_id: usize) -> Result<()> {
862        let queued = {
863            let mut inner = self.doc.lock();
864            let dto = frontend::document_editing::RemoveTableDto {
865                table_id: to_i64(table_id),
866            };
867            document_editing_commands::remove_table(&inner.ctx, Some(inner.stack_id), &dto)?;
868            inner.modified = true;
869            inner.invalidate_text_cache();
870            inner.rehighlight_all();
871            inner.check_block_count_changed();
872            inner.check_flow_changed();
873            self.queue_undo_redo_event(&mut inner)
874        };
875        crate::inner::dispatch_queued_events(queued);
876        Ok(())
877    }
878
879    /// Insert a row into a table at the given index.
880    pub fn insert_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
881        let queued = {
882            let mut inner = self.doc.lock();
883            let dto = frontend::document_editing::InsertTableRowDto {
884                table_id: to_i64(table_id),
885                row_index: to_i64(row_index),
886            };
887            document_editing_commands::insert_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
888            inner.modified = true;
889            inner.invalidate_text_cache();
890            inner.rehighlight_all();
891            inner.check_block_count_changed();
892            self.queue_undo_redo_event(&mut inner)
893        };
894        crate::inner::dispatch_queued_events(queued);
895        Ok(())
896    }
897
898    /// Insert a column into a table at the given index.
899    pub fn insert_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
900        let queued = {
901            let mut inner = self.doc.lock();
902            let dto = frontend::document_editing::InsertTableColumnDto {
903                table_id: to_i64(table_id),
904                column_index: to_i64(column_index),
905            };
906            document_editing_commands::insert_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
907            inner.modified = true;
908            inner.invalidate_text_cache();
909            inner.rehighlight_all();
910            inner.check_block_count_changed();
911            self.queue_undo_redo_event(&mut inner)
912        };
913        crate::inner::dispatch_queued_events(queued);
914        Ok(())
915    }
916
917    /// Remove a row from a table. Fails if only one row remains.
918    pub fn remove_table_row(&self, table_id: usize, row_index: usize) -> Result<()> {
919        let queued = {
920            let mut inner = self.doc.lock();
921            let dto = frontend::document_editing::RemoveTableRowDto {
922                table_id: to_i64(table_id),
923                row_index: to_i64(row_index),
924            };
925            document_editing_commands::remove_table_row(&inner.ctx, Some(inner.stack_id), &dto)?;
926            inner.modified = true;
927            inner.invalidate_text_cache();
928            inner.rehighlight_all();
929            inner.check_block_count_changed();
930            self.queue_undo_redo_event(&mut inner)
931        };
932        crate::inner::dispatch_queued_events(queued);
933        Ok(())
934    }
935
936    /// Remove a column from a table. Fails if only one column remains.
937    pub fn remove_table_column(&self, table_id: usize, column_index: usize) -> Result<()> {
938        let queued = {
939            let mut inner = self.doc.lock();
940            let dto = frontend::document_editing::RemoveTableColumnDto {
941                table_id: to_i64(table_id),
942                column_index: to_i64(column_index),
943            };
944            document_editing_commands::remove_table_column(&inner.ctx, Some(inner.stack_id), &dto)?;
945            inner.modified = true;
946            inner.invalidate_text_cache();
947            inner.rehighlight_all();
948            inner.check_block_count_changed();
949            self.queue_undo_redo_event(&mut inner)
950        };
951        crate::inner::dispatch_queued_events(queued);
952        Ok(())
953    }
954
955    /// Merge a rectangular range of cells within a table.
956    pub fn merge_table_cells(
957        &self,
958        table_id: usize,
959        start_row: usize,
960        start_column: usize,
961        end_row: usize,
962        end_column: usize,
963    ) -> Result<()> {
964        let queued = {
965            let mut inner = self.doc.lock();
966            let dto = frontend::document_editing::MergeTableCellsDto {
967                table_id: to_i64(table_id),
968                start_row: to_i64(start_row),
969                start_column: to_i64(start_column),
970                end_row: to_i64(end_row),
971                end_column: to_i64(end_column),
972            };
973            document_editing_commands::merge_table_cells(&inner.ctx, Some(inner.stack_id), &dto)?;
974            inner.modified = true;
975            inner.invalidate_text_cache();
976            inner.rehighlight_all();
977            inner.check_block_count_changed();
978            self.queue_undo_redo_event(&mut inner)
979        };
980        crate::inner::dispatch_queued_events(queued);
981        Ok(())
982    }
983
984    /// Split a previously merged cell.
985    pub fn split_table_cell(
986        &self,
987        cell_id: usize,
988        split_rows: usize,
989        split_columns: usize,
990    ) -> Result<()> {
991        let queued = {
992            let mut inner = self.doc.lock();
993            let dto = frontend::document_editing::SplitTableCellDto {
994                cell_id: to_i64(cell_id),
995                split_rows: to_i64(split_rows),
996                split_columns: to_i64(split_columns),
997            };
998            document_editing_commands::split_table_cell(&inner.ctx, Some(inner.stack_id), &dto)?;
999            inner.modified = true;
1000            inner.invalidate_text_cache();
1001            inner.rehighlight_all();
1002            inner.check_block_count_changed();
1003            self.queue_undo_redo_event(&mut inner)
1004        };
1005        crate::inner::dispatch_queued_events(queued);
1006        Ok(())
1007    }
1008
1009    // ── Table formatting (explicit-ID) ───────────────────
1010
1011    /// Set formatting on a table.
1012    pub fn set_table_format(
1013        &self,
1014        table_id: usize,
1015        format: &crate::flow::TableFormat,
1016    ) -> Result<()> {
1017        let queued = {
1018            let mut inner = self.doc.lock();
1019            let dto = format.to_set_dto(table_id);
1020            document_formatting_commands::set_table_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1021            inner.modified = true;
1022            inner.queue_event(DocumentEvent::FormatChanged {
1023                position: 0,
1024                length: 0,
1025                kind: crate::flow::FormatChangeKind::Block,
1026            });
1027            self.queue_undo_redo_event(&mut inner)
1028        };
1029        crate::inner::dispatch_queued_events(queued);
1030        Ok(())
1031    }
1032
1033    /// Set formatting on a table cell.
1034    pub fn set_table_cell_format(
1035        &self,
1036        cell_id: usize,
1037        format: &crate::flow::CellFormat,
1038    ) -> Result<()> {
1039        let queued = {
1040            let mut inner = self.doc.lock();
1041            let dto = format.to_set_dto(cell_id);
1042            document_formatting_commands::set_table_cell_format(
1043                &inner.ctx,
1044                Some(inner.stack_id),
1045                &dto,
1046            )?;
1047            inner.modified = true;
1048            inner.queue_event(DocumentEvent::FormatChanged {
1049                position: 0,
1050                length: 0,
1051                kind: crate::flow::FormatChangeKind::Block,
1052            });
1053            self.queue_undo_redo_event(&mut inner)
1054        };
1055        crate::inner::dispatch_queued_events(queued);
1056        Ok(())
1057    }
1058
1059    // ── Table convenience (position-based) ───────────────
1060
1061    /// Remove the table the cursor is currently inside.
1062    /// Returns an error if the cursor is not inside a table.
1063    pub fn remove_current_table(&self) -> Result<()> {
1064        let table = self
1065            .current_table()
1066            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1067        self.remove_table(table.id())
1068    }
1069
1070    /// Insert a row above the cursor's current row.
1071    /// Returns an error if the cursor is not inside a table.
1072    pub fn insert_row_above(&self) -> Result<()> {
1073        let cell_ref = self
1074            .current_table_cell()
1075            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1076        self.insert_table_row(cell_ref.table.id(), cell_ref.row)
1077    }
1078
1079    /// Insert a row below the cursor's current row.
1080    /// Returns an error if the cursor is not inside a table.
1081    pub fn insert_row_below(&self) -> Result<()> {
1082        let cell_ref = self
1083            .current_table_cell()
1084            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1085        self.insert_table_row(cell_ref.table.id(), cell_ref.row + 1)
1086    }
1087
1088    /// Insert a column before the cursor's current column.
1089    /// Returns an error if the cursor is not inside a table.
1090    pub fn insert_column_before(&self) -> Result<()> {
1091        let cell_ref = self
1092            .current_table_cell()
1093            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1094        self.insert_table_column(cell_ref.table.id(), cell_ref.column)
1095    }
1096
1097    /// Insert a column after the cursor's current column.
1098    /// Returns an error if the cursor is not inside a table.
1099    pub fn insert_column_after(&self) -> Result<()> {
1100        let cell_ref = self
1101            .current_table_cell()
1102            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1103        self.insert_table_column(cell_ref.table.id(), cell_ref.column + 1)
1104    }
1105
1106    /// Remove the row at the cursor's current position.
1107    /// Returns an error if the cursor is not inside a table.
1108    pub fn remove_current_row(&self) -> Result<()> {
1109        let cell_ref = self
1110            .current_table_cell()
1111            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1112        self.remove_table_row(cell_ref.table.id(), cell_ref.row)
1113    }
1114
1115    /// Remove the column at the cursor's current position.
1116    /// Returns an error if the cursor is not inside a table.
1117    pub fn remove_current_column(&self) -> Result<()> {
1118        let cell_ref = self
1119            .current_table_cell()
1120            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1121        self.remove_table_column(cell_ref.table.id(), cell_ref.column)
1122    }
1123
1124    /// Merge cells spanned by the current selection.
1125    ///
1126    /// Both cursor position and anchor must be inside the same table.
1127    /// The cell range is derived from the cells at position and anchor.
1128    /// Returns an error if the cursor is not inside a table or position
1129    /// and anchor are in different tables.
1130    pub fn merge_selected_cells(&self) -> Result<()> {
1131        let pos_cell = self
1132            .current_table_cell()
1133            .ok_or_else(|| anyhow::anyhow!("cursor position is not inside a table"))?;
1134
1135        // Get anchor cell
1136        let (_pos, anchor) = self.read_cursor();
1137        let anchor_cell = {
1138            // Create a temporary block handle at the anchor position
1139            let inner = self.doc.lock();
1140            let dto = frontend::document_inspection::GetBlockAtPositionDto {
1141                position: to_i64(anchor),
1142            };
1143            let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
1144                .map_err(|_| anyhow::anyhow!("cursor anchor is not inside a table"))?;
1145            let block = crate::text_block::TextBlock {
1146                doc: self.doc.clone(),
1147                block_id: block_info.block_id as usize,
1148            };
1149            drop(inner);
1150            block
1151                .table_cell()
1152                .ok_or_else(|| anyhow::anyhow!("cursor anchor is not inside a table"))?
1153        };
1154
1155        if pos_cell.table.id() != anchor_cell.table.id() {
1156            return Err(anyhow::anyhow!(
1157                "position and anchor are in different tables"
1158            ));
1159        }
1160
1161        let start_row = pos_cell.row.min(anchor_cell.row);
1162        let start_col = pos_cell.column.min(anchor_cell.column);
1163        let end_row = pos_cell.row.max(anchor_cell.row);
1164        let end_col = pos_cell.column.max(anchor_cell.column);
1165
1166        self.merge_table_cells(pos_cell.table.id(), start_row, start_col, end_row, end_col)
1167    }
1168
1169    /// Split the cell at the cursor's current position.
1170    /// Returns an error if the cursor is not inside a table.
1171    pub fn split_current_cell(&self, split_rows: usize, split_columns: usize) -> Result<()> {
1172        let cell_ref = self
1173            .current_table_cell()
1174            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1175        // Get the cell entity ID from the table handle
1176        let cell = cell_ref
1177            .table
1178            .cell(cell_ref.row, cell_ref.column)
1179            .ok_or_else(|| anyhow::anyhow!("cell not found"))?;
1180        // TextTableCell stores cell_id
1181        self.split_table_cell(cell.id(), split_rows, split_columns)
1182    }
1183
1184    /// Set formatting on the table the cursor is currently inside.
1185    /// Returns an error if the cursor is not inside a table.
1186    pub fn set_current_table_format(&self, format: &crate::flow::TableFormat) -> Result<()> {
1187        let table = self
1188            .current_table()
1189            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1190        self.set_table_format(table.id(), format)
1191    }
1192
1193    /// Set formatting on the cell the cursor is currently inside.
1194    /// Returns an error if the cursor is not inside a table.
1195    pub fn set_current_cell_format(&self, format: &crate::flow::CellFormat) -> Result<()> {
1196        let cell_ref = self
1197            .current_table_cell()
1198            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a table"))?;
1199        let cell = cell_ref
1200            .table
1201            .cell(cell_ref.row, cell_ref.column)
1202            .ok_or_else(|| anyhow::anyhow!("cell not found"))?;
1203        self.set_table_cell_format(cell.id(), format)
1204    }
1205
1206    // ── Cell selection queries ────────────────────────────────
1207
1208    /// Determine the kind of selection the cursor currently has.
1209    ///
1210    /// Returns [`Cells`](crate::SelectionKind::Cells) when position and anchor are in
1211    /// different cells of the same table (rectangular cell selection), or
1212    /// when an explicit cell-selection override is active.
1213    pub fn selection_kind(&self) -> crate::flow::SelectionKind {
1214        use crate::flow::{CellRange, SelectionKind};
1215
1216        // Check override first
1217        {
1218            let d = self.data.lock();
1219            if let Some(ref range) = d.cell_selection_override {
1220                return SelectionKind::Cells(range.clone());
1221            }
1222            if d.position == d.anchor {
1223                return SelectionKind::None;
1224            }
1225        }
1226
1227        let (pos, anchor) = self.read_cursor();
1228
1229        // Look up table cell for position and anchor
1230        let pos_cell = self.table_cell_at(pos);
1231        let anchor_cell = self.table_cell_at(anchor);
1232
1233        match (&pos_cell, &anchor_cell) {
1234            (None, None) => {
1235                // Both endpoints are outside tables. Check whether a table
1236                // sits between them — if so, all its cells must be selected
1237                // (Word behaviour).
1238                let (start, end) = (pos.min(anchor), pos.max(anchor));
1239                if let Some(t) = self.find_table_between(start, end) {
1240                    let table_id = t.id();
1241                    let rows = t.rows();
1242                    let cols = t.columns();
1243                    let range = CellRange {
1244                        table_id,
1245                        start_row: 0,
1246                        start_col: 0,
1247                        end_row: if rows > 0 { rows - 1 } else { 0 },
1248                        end_col: if cols > 0 { cols - 1 } else { 0 },
1249                    };
1250                    let spans = self.collect_cell_spans(table_id);
1251                    SelectionKind::Mixed {
1252                        cell_range: range.expand_for_spans(&spans),
1253                        text_before: true,
1254                        text_after: true,
1255                    }
1256                } else {
1257                    SelectionKind::Text
1258                }
1259            }
1260            (Some(pc), Some(ac)) => {
1261                if pc.table.id() != ac.table.id() {
1262                    // Different tables — treat as text (whole tables selected between them)
1263                    return SelectionKind::Text;
1264                }
1265                if pc.row == ac.row && pc.column == ac.column {
1266                    // Same cell — text selection within one cell
1267                    return SelectionKind::Text;
1268                }
1269                // Different cells, same table — rectangular cell selection
1270                let range = CellRange {
1271                    table_id: pc.table.id(),
1272                    start_row: pc.row.min(ac.row),
1273                    start_col: pc.column.min(ac.column),
1274                    end_row: pc.row.max(ac.row),
1275                    end_col: pc.column.max(ac.column),
1276                };
1277                let spans = self.collect_cell_spans(pc.table.id());
1278                SelectionKind::Cells(range.expand_for_spans(&spans))
1279            }
1280            (Some(tc), None) | (None, Some(tc)) => {
1281                // One endpoint inside a table, the other outside — mixed
1282                // selection.  Following Word behaviour, select ALL cells in
1283                // the table (not just from the entry edge to the cursor row).
1284                let table_id = tc.table.id();
1285                let rows = tc.table.rows();
1286                let cols = tc.table.columns();
1287
1288                let inside_pos = if pos_cell.is_some() { pos } else { anchor };
1289                let outside_pos = if pos_cell.is_some() { anchor } else { pos };
1290
1291                let text_before = outside_pos < inside_pos;
1292                let text_after = !text_before;
1293
1294                let range = CellRange {
1295                    table_id,
1296                    start_row: 0,
1297                    start_col: 0,
1298                    end_row: if rows > 0 { rows - 1 } else { 0 },
1299                    end_col: if cols > 0 { cols - 1 } else { 0 },
1300                };
1301                let spans = self.collect_cell_spans(table_id);
1302                SelectionKind::Mixed {
1303                    cell_range: range.expand_for_spans(&spans),
1304                    text_before,
1305                    text_after,
1306                }
1307            }
1308        }
1309    }
1310
1311    /// Returns `true` when the current selection involves whole-cell selection.
1312    pub fn is_cell_selection(&self) -> bool {
1313        matches!(
1314            self.selection_kind(),
1315            crate::flow::SelectionKind::Cells(_) | crate::flow::SelectionKind::Mixed { .. }
1316        )
1317    }
1318
1319    /// Returns the rectangular cell range if the cursor has a cell selection.
1320    pub fn selected_cell_range(&self) -> Option<crate::flow::CellRange> {
1321        match self.selection_kind() {
1322            crate::flow::SelectionKind::Cells(r) => Some(r),
1323            crate::flow::SelectionKind::Mixed { cell_range, .. } => Some(cell_range),
1324            _ => None,
1325        }
1326    }
1327
1328    /// Returns all cells in the selected rectangular range.
1329    pub fn selected_cells(&self) -> Vec<TableCellRef> {
1330        let range = match self.selected_cell_range() {
1331            Some(r) => r,
1332            None => return Vec::new(),
1333        };
1334        let table = TextTable {
1335            doc: self.doc.clone(),
1336            table_id: range.table_id,
1337        };
1338        let mut cells = Vec::new();
1339        for row in range.start_row..=range.end_row {
1340            for col in range.start_col..=range.end_col {
1341                if table.cell(row, col).is_some() {
1342                    cells.push(TableCellRef {
1343                        table: table.clone(),
1344                        row,
1345                        column: col,
1346                    });
1347                }
1348            }
1349        }
1350        cells
1351    }
1352
1353    // ── Explicit cell selection ─────────────────────────────
1354
1355    /// Set an explicit single-cell selection override.
1356    pub fn select_table_cell(&self, table_id: usize, row: usize, col: usize) {
1357        let mut d = self.data.lock();
1358        d.cell_selection_override = Some(crate::flow::CellRange {
1359            table_id,
1360            start_row: row,
1361            start_col: col,
1362            end_row: row,
1363            end_col: col,
1364        });
1365    }
1366
1367    /// Set an explicit rectangular cell-range selection override.
1368    pub fn select_cell_range(
1369        &self,
1370        table_id: usize,
1371        start_row: usize,
1372        start_col: usize,
1373        end_row: usize,
1374        end_col: usize,
1375    ) {
1376        let range = crate::flow::CellRange {
1377            table_id,
1378            start_row,
1379            start_col,
1380            end_row,
1381            end_col,
1382        };
1383        let spans = self.collect_cell_spans(table_id);
1384        let mut d = self.data.lock();
1385        d.cell_selection_override = Some(range.expand_for_spans(&spans));
1386    }
1387
1388    /// Clear any cell-selection override without changing position/anchor.
1389    pub fn clear_cell_selection(&self) {
1390        let mut d = self.data.lock();
1391        d.cell_selection_override = None;
1392    }
1393
1394    /// Compute (min_position, max_position) spanning all blocks in a cell range.
1395    /// Returns `None` if the table or cells cannot be found.
1396    fn cell_range_positions(&self, range: &CellRange) -> Option<(usize, usize)> {
1397        let inner = self.doc.lock();
1398        let main_frame_id = get_main_frame_id(&inner);
1399        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1400        drop(inner);
1401
1402        // Find the table matching the range's table_id
1403        let table = flow.into_iter().find_map(|e| match e {
1404            FlowElement::Table(t) if t.id() == range.table_id => Some(t),
1405            _ => None,
1406        })?;
1407
1408        let mut min_pos = usize::MAX;
1409        let mut max_pos = 0usize;
1410
1411        for row in range.start_row..=range.end_row {
1412            for col in range.start_col..=range.end_col {
1413                if let Some(cell) = table.cell(row, col) {
1414                    for block in cell.blocks() {
1415                        let bp = block.position();
1416                        let bl = block.length();
1417                        min_pos = min_pos.min(bp);
1418                        max_pos = max_pos.max(bp + bl);
1419                    }
1420                }
1421            }
1422        }
1423
1424        if min_pos == usize::MAX {
1425            return None;
1426        }
1427
1428        // Extend max_pos past the last block to ensure cross-cell detection
1429        Some((min_pos, max_pos + 1))
1430    }
1431
1432    // ── Cell selection helpers (private) ─────────────────────
1433
1434    /// Look up which table cell contains the given document position, if any.
1435    fn table_cell_at(&self, position: usize) -> Option<TableCellRef> {
1436        let inner = self.doc.lock();
1437        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1438            position: to_i64(position),
1439        };
1440        let block_info =
1441            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1442
1443        let block_id = if to_i64(position) < block_info.block_start && position > 0 {
1444            let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
1445                position: to_i64(position - 1),
1446            };
1447            let prev_info =
1448                document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto).ok()?;
1449            prev_info.block_id as usize
1450        } else {
1451            block_info.block_id as usize
1452        };
1453
1454        let block = crate::text_block::TextBlock {
1455            doc: self.doc.clone(),
1456            block_id,
1457        };
1458        drop(inner);
1459        block.table_cell()
1460    }
1461
1462    /// Find the document position at the boundary of the block adjacent to a
1463    /// table. Used by the table-trap logic in [`set_position`](Self::set_position).
1464    ///
1465    /// - `before == true`: returns the last position of the block immediately
1466    ///   before the table (i.e. `block.position() + block.length()`).
1467    /// - `before == false`: returns the first position of the block immediately
1468    ///   after the table.
1469    ///
1470    /// Returns `None` when no adjacent block exists (table is first or last
1471    /// element in the flow).
1472    fn table_boundary_position(&self, table_id: usize, before: bool) -> Option<usize> {
1473        let inner = self.doc.lock();
1474        let main_frame_id = get_main_frame_id(&inner);
1475        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1476        drop(inner);
1477
1478        // Find the table in the flow and peek at the adjacent element.
1479        let idx = flow
1480            .iter()
1481            .position(|e| matches!(e, FlowElement::Table(t) if t.id() == table_id))?;
1482
1483        if before {
1484            // Walk backwards to find the nearest Block.
1485            for i in (0..idx).rev() {
1486                if let FlowElement::Block(b) = &flow[i] {
1487                    return Some(b.position() + b.length());
1488                }
1489            }
1490        } else {
1491            // Walk forwards to find the nearest Block.
1492            for item in flow.iter().skip(idx + 1) {
1493                if let FlowElement::Block(b) = item {
1494                    return Some(b.position());
1495                }
1496            }
1497        }
1498        None
1499    }
1500
1501    /// Find the first table whose cell blocks fall within the range `(start, end)`.
1502    fn find_table_between(&self, start: usize, end: usize) -> Option<TextTable> {
1503        let inner = self.doc.lock();
1504        let main_frame_id = get_main_frame_id(&inner);
1505        let flow = crate::text_frame::build_flow_elements(&inner, &self.doc, main_frame_id);
1506        drop(inner);
1507
1508        for elem in flow {
1509            if let FlowElement::Table(t) = elem {
1510                // Check whether the first cell's block position is between
1511                // the two endpoints (i.e. the table is inside the range).
1512                if let Some(first_cell) = t.cell(0, 0) {
1513                    let blocks = first_cell.blocks();
1514                    if let Some(fb) = blocks.first() {
1515                        let p = fb.position();
1516                        if p > start && p < end {
1517                            return Some(t);
1518                        }
1519                    }
1520                }
1521            }
1522        }
1523        None
1524    }
1525
1526    /// Collect `(row, col, row_span, col_span)` tuples for all cells in a table.
1527    fn collect_cell_spans(&self, table_id: usize) -> Vec<(usize, usize, usize, usize)> {
1528        let inner = self.doc.lock();
1529        let table_dto =
1530            match frontend::commands::table_commands::get_table(&inner.ctx, &(table_id as u64))
1531                .ok()
1532                .flatten()
1533            {
1534                Some(t) => t,
1535                None => return Vec::new(),
1536            };
1537
1538        let mut spans = Vec::with_capacity(table_dto.cells.len());
1539        for &cell_id in &table_dto.cells {
1540            if let Some(cell) =
1541                frontend::commands::table_cell_commands::get_table_cell(&inner.ctx, &cell_id)
1542                    .ok()
1543                    .flatten()
1544            {
1545                spans.push((
1546                    cell.row as usize,
1547                    cell.column as usize,
1548                    cell.row_span.max(1) as usize,
1549                    cell.column_span.max(1) as usize,
1550                ));
1551            }
1552        }
1553        spans
1554    }
1555
1556    /// Delete the character after the cursor (Delete key).
1557    pub fn delete_char(&self) -> Result<()> {
1558        let (pos, anchor) = self.read_cursor();
1559        let (del_pos, del_anchor) = if pos != anchor {
1560            (pos, anchor)
1561        } else {
1562            // No-op at end of document (symmetric with delete_previous_char at start)
1563            let end = {
1564                let inner = self.doc.lock();
1565                document_inspection_commands::get_document_stats(&inner.ctx)
1566                    .map(|s| max_cursor_position(&s))
1567                    .unwrap_or(0)
1568            };
1569            if pos >= end {
1570                return Ok(());
1571            }
1572            // Delete the whole grapheme cluster after the cursor so a
1573            // single Delete on `👋🏻` or `e\u{0301}` removes the
1574            // user-perceived character, not just its first scalar.
1575            let to = self.next_grapheme_boundary(pos);
1576            if to == pos {
1577                return Ok(());
1578            }
1579            (pos, to)
1580        };
1581        self.do_delete(del_pos, del_anchor)
1582    }
1583
1584    /// Delete the character before the cursor (Backspace key).
1585    pub fn delete_previous_char(&self) -> Result<()> {
1586        let (pos, anchor) = self.read_cursor();
1587        let (del_pos, del_anchor) = if pos != anchor {
1588            (pos, anchor)
1589        } else if pos > 0 {
1590            let from = self.prev_grapheme_boundary(pos);
1591            if from == pos {
1592                return Ok(());
1593            }
1594            (from, pos)
1595        } else {
1596            return Ok(());
1597        };
1598        self.do_delete(del_pos, del_anchor)
1599    }
1600
1601    /// Delete the selected text. Returns the deleted text. No-op if no selection.
1602    pub fn remove_selected_text(&self) -> Result<String> {
1603        let (pos, anchor) = self.read_cursor();
1604        if pos == anchor {
1605            return Ok(String::new());
1606        }
1607        let queued = {
1608            let mut inner = self.doc.lock();
1609            let dto = frontend::document_editing::DeleteTextDto {
1610                position: to_i64(pos),
1611                anchor: to_i64(anchor),
1612            };
1613            let result =
1614                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
1615            let edit_pos = pos.min(anchor);
1616            let removed = pos.max(anchor) - edit_pos;
1617            let new_pos = to_usize(result.new_position);
1618            inner.adjust_cursors(edit_pos, removed, 0);
1619            {
1620                let mut d = self.data.lock();
1621                d.position = new_pos;
1622                d.anchor = new_pos;
1623            }
1624            inner.modified = true;
1625            inner.invalidate_text_cache();
1626            inner.rehighlight_affected(edit_pos);
1627            inner.queue_event(DocumentEvent::ContentsChanged {
1628                position: edit_pos,
1629                chars_removed: removed,
1630                chars_added: 0,
1631                blocks_affected: 1,
1632            });
1633            inner.check_block_count_changed();
1634            inner.check_flow_changed();
1635            // Return the deleted text alongside the queued events
1636            (result.deleted_text, self.queue_undo_redo_event(&mut inner))
1637        };
1638        crate::inner::dispatch_queued_events(queued.1);
1639        Ok(queued.0)
1640    }
1641
1642    // ── List operations ──────────────────────────────────────
1643
1644    /// Returns the list that the block at the cursor position belongs to,
1645    /// or `None` if the current block is not a list item.
1646    pub fn current_list(&self) -> Option<crate::TextList> {
1647        let pos = self.position();
1648        let inner = self.doc.lock();
1649        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1650            position: to_i64(pos),
1651        };
1652        let block_info =
1653            document_inspection_commands::get_block_at_position(&inner.ctx, &dto).ok()?;
1654        let block = crate::text_block::TextBlock {
1655            doc: self.doc.clone(),
1656            block_id: block_info.block_id as usize,
1657        };
1658        drop(inner);
1659        block.list()
1660    }
1661
1662    /// Turn the block(s) in the selection into a list.
1663    pub fn create_list(&self, style: ListStyle) -> Result<()> {
1664        let (pos, anchor) = self.read_cursor();
1665        let queued = {
1666            let mut inner = self.doc.lock();
1667            let dto = frontend::document_editing::CreateListDto {
1668                position: to_i64(pos),
1669                anchor: to_i64(anchor),
1670                style: style.clone(),
1671            };
1672            document_editing_commands::create_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1673            inner.modified = true;
1674            inner.rehighlight_affected(pos.min(anchor));
1675            inner.queue_event(DocumentEvent::ContentsChanged {
1676                position: pos.min(anchor),
1677                chars_removed: 0,
1678                chars_added: 0,
1679                blocks_affected: 1,
1680            });
1681            self.queue_undo_redo_event(&mut inner)
1682        };
1683        crate::inner::dispatch_queued_events(queued);
1684        Ok(())
1685    }
1686
1687    /// Insert a new list item at the cursor position.
1688    pub fn insert_list(&self, style: ListStyle) -> Result<()> {
1689        let (pos, anchor) = self.read_cursor();
1690        let queued = {
1691            let mut inner = self.doc.lock();
1692            let dto = frontend::document_editing::InsertListDto {
1693                position: to_i64(pos),
1694                anchor: to_i64(anchor),
1695                style: style.clone(),
1696            };
1697            let result =
1698                document_editing_commands::insert_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1699            let edit_pos = pos.min(anchor);
1700            let removed = pos.max(anchor) - edit_pos;
1701            self.finish_edit_ext(
1702                &mut inner,
1703                edit_pos,
1704                removed,
1705                to_usize(result.new_position),
1706                1,
1707                false,
1708            )
1709        };
1710        crate::inner::dispatch_queued_events(queued);
1711        Ok(())
1712    }
1713
1714    /// Set formatting on a list by its ID.
1715    pub fn set_list_format(&self, list_id: usize, format: &crate::ListFormat) -> Result<()> {
1716        let queued = {
1717            let mut inner = self.doc.lock();
1718            let dto = format.to_set_dto(list_id);
1719            document_formatting_commands::set_list_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1720            inner.modified = true;
1721            inner.queue_event(DocumentEvent::FormatChanged {
1722                position: 0,
1723                length: 0,
1724                kind: crate::flow::FormatChangeKind::List,
1725            });
1726            self.queue_undo_redo_event(&mut inner)
1727        };
1728        crate::inner::dispatch_queued_events(queued);
1729        Ok(())
1730    }
1731
1732    /// Set formatting on the list that the current block belongs to.
1733    /// Returns an error if the cursor is not inside a list item.
1734    pub fn set_current_list_format(&self, format: &crate::ListFormat) -> Result<()> {
1735        let list = self
1736            .current_list()
1737            .ok_or_else(|| anyhow::anyhow!("cursor is not inside a list"))?;
1738        self.set_list_format(list.id(), format)
1739    }
1740
1741    /// Add a block to a list by their IDs.
1742    pub fn add_block_to_list(&self, block_id: usize, list_id: usize) -> Result<()> {
1743        let queued = {
1744            let mut inner = self.doc.lock();
1745            let dto = frontend::document_editing::AddBlockToListDto {
1746                block_id: to_i64(block_id),
1747                list_id: to_i64(list_id),
1748            };
1749            document_editing_commands::add_block_to_list(&inner.ctx, Some(inner.stack_id), &dto)?;
1750            inner.modified = true;
1751            // List membership is a formatting/layout concern, not a text
1752            // change — fire FormatChanged so consumers re-layout (the
1753            // block's horizontal position and list marker depend on
1754            // its list assignment). ContentsChanged with position=0
1755            // was misleading and caused incremental relayouts to
1756            // re-shape the wrong block.
1757            inner.queue_event(DocumentEvent::FormatChanged {
1758                position: 0,
1759                length: 0,
1760                kind: crate::flow::FormatChangeKind::List,
1761            });
1762            self.queue_undo_redo_event(&mut inner)
1763        };
1764        crate::inner::dispatch_queued_events(queued);
1765        Ok(())
1766    }
1767
1768    /// Add the block at the cursor position to a list.
1769    pub fn add_current_block_to_list(&self, list_id: usize) -> Result<()> {
1770        let pos = self.position();
1771        let inner = self.doc.lock();
1772        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1773            position: to_i64(pos),
1774        };
1775        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1776        drop(inner);
1777        self.add_block_to_list(block_info.block_id as usize, list_id)
1778    }
1779
1780    /// Remove a block from its list by block ID.
1781    pub fn remove_block_from_list(&self, block_id: usize) -> Result<()> {
1782        let queued = {
1783            let mut inner = self.doc.lock();
1784            let dto = frontend::document_editing::RemoveBlockFromListDto {
1785                block_id: to_i64(block_id),
1786            };
1787            document_editing_commands::remove_block_from_list(
1788                &inner.ctx,
1789                Some(inner.stack_id),
1790                &dto,
1791            )?;
1792            inner.modified = true;
1793            // See `add_block_to_list` — list-membership is a
1794            // formatting/layout change, not a text content change.
1795            inner.queue_event(DocumentEvent::FormatChanged {
1796                position: 0,
1797                length: 0,
1798                kind: crate::flow::FormatChangeKind::List,
1799            });
1800            self.queue_undo_redo_event(&mut inner)
1801        };
1802        crate::inner::dispatch_queued_events(queued);
1803        Ok(())
1804    }
1805
1806    /// Remove the block at the cursor position from its list.
1807    /// Returns an error if the current block is not a list item.
1808    pub fn remove_current_block_from_list(&self) -> Result<()> {
1809        let pos = self.position();
1810        let inner = self.doc.lock();
1811        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1812            position: to_i64(pos),
1813        };
1814        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1815        drop(inner);
1816        self.remove_block_from_list(block_info.block_id as usize)
1817    }
1818
1819    /// Remove a list item by index within the list.
1820    /// Resolves the index to a block, then removes it from the list.
1821    pub fn remove_list_item(&self, list_id: usize, index: usize) -> Result<()> {
1822        let list = crate::text_list::TextList {
1823            doc: self.doc.clone(),
1824            list_id,
1825        };
1826        let block = list
1827            .item(index)
1828            .ok_or_else(|| anyhow::anyhow!("list item index {index} out of range"))?;
1829        self.remove_block_from_list(block.id())
1830    }
1831
1832    // ── Format queries ───────────────────────────────────────
1833
1834    /// Get the character format at the cursor position. Reads the
1835    /// covering `FormatRun` (or image anchor) directly from the store.
1836    pub fn char_format(&self) -> Result<TextFormat> {
1837        let pos = self.position();
1838        let inner = self.doc.lock();
1839
1840        // Locate the block containing the cursor.
1841        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1842            position: to_i64(pos),
1843        };
1844        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1845        let block_id = block_info.block_id as u64;
1846        let mut block_dto = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
1847            .ok_or_else(|| anyhow::anyhow!("block not found at position"))?;
1848        let store = inner.ctx.db_context.get_store();
1849        crate::inner::refresh_block_position(&mut block_dto, store);
1850
1851        // Convert document-wide char position to a byte offset within
1852        // the block's content (read from the rope).
1853        let local_char = pos.saturating_sub(block_dto.document_position as usize);
1854        let entity: common::entities::Block = block_dto.clone().into();
1855        let plain_owned = common::database::rope_helpers::block_content_via_store(&entity, store);
1856        let plain: &str = &plain_owned;
1857        let byte_offset: u32 = plain
1858            .char_indices()
1859            .nth(local_char)
1860            .map(|(b, _)| b as u32)
1861            .unwrap_or(plain.len() as u32);
1862
1863        // If there's an image anchor at this exact byte position, use
1864        // its format.
1865        let images = store
1866            .block_images
1867            .read()
1868            .unwrap()
1869            .get(&block_id)
1870            .cloned()
1871            .unwrap_or_default();
1872        if let Some(img) = images.iter().find(|i| i.byte_offset == byte_offset) {
1873            return Ok(TextFormat::from(&img.format));
1874        }
1875
1876        // Otherwise find the FormatRun covering the byte position.
1877        let runs = store
1878            .format_runs
1879            .read()
1880            .unwrap()
1881            .get(&block_id)
1882            .cloned()
1883            .unwrap_or_default();
1884        let fmt = runs
1885            .iter()
1886            .find(|r| r.byte_start <= byte_offset && byte_offset < r.byte_end)
1887            .map(|r| TextFormat::from(&r.format))
1888            .unwrap_or_default();
1889        Ok(fmt)
1890    }
1891
1892    /// Get the block format of the block containing the cursor.
1893    pub fn block_format(&self) -> Result<BlockFormat> {
1894        let pos = self.position();
1895        let inner = self.doc.lock();
1896        let dto = frontend::document_inspection::GetBlockAtPositionDto {
1897            position: to_i64(pos),
1898        };
1899        let block_info = document_inspection_commands::get_block_at_position(&inner.ctx, &dto)?;
1900        let block_id = block_info.block_id as u64;
1901        let block = frontend::commands::block_commands::get_block(&inner.ctx, &block_id)?
1902            .ok_or_else(|| anyhow::anyhow!("block not found"))?;
1903        Ok(BlockFormat::from(&block))
1904    }
1905
1906    // ── Format application ───────────────────────────────────
1907
1908    /// Set the character format for the selection.
1909    pub fn set_char_format(&self, format: &TextFormat) -> Result<()> {
1910        let (pos, anchor) = self.read_cursor();
1911        let queued = {
1912            let mut inner = self.doc.lock();
1913            let dto = format.to_set_dto(pos, anchor);
1914            document_formatting_commands::set_text_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1915            let start = pos.min(anchor);
1916            let length = pos.max(anchor) - start;
1917            inner.modified = true;
1918            inner.queue_event(DocumentEvent::FormatChanged {
1919                position: start,
1920                length,
1921                kind: crate::flow::FormatChangeKind::Character,
1922            });
1923            self.queue_undo_redo_event(&mut inner)
1924        };
1925        crate::inner::dispatch_queued_events(queued);
1926        Ok(())
1927    }
1928
1929    /// Merge a character format into the selection.
1930    pub fn merge_char_format(&self, format: &TextFormat) -> Result<()> {
1931        let (pos, anchor) = self.read_cursor();
1932        let queued = {
1933            let mut inner = self.doc.lock();
1934            let dto = format.to_merge_dto(pos, anchor);
1935            document_formatting_commands::merge_text_format(
1936                &inner.ctx,
1937                Some(inner.stack_id),
1938                &dto,
1939            )?;
1940            let start = pos.min(anchor);
1941            let length = pos.max(anchor) - start;
1942            inner.modified = true;
1943            inner.queue_event(DocumentEvent::FormatChanged {
1944                position: start,
1945                length,
1946                kind: crate::flow::FormatChangeKind::Character,
1947            });
1948            self.queue_undo_redo_event(&mut inner)
1949        };
1950        crate::inner::dispatch_queued_events(queued);
1951        Ok(())
1952    }
1953
1954    /// Set the block format for the current block (or all blocks in selection).
1955    pub fn set_block_format(&self, format: &BlockFormat) -> Result<()> {
1956        let (pos, anchor) = self.read_cursor();
1957        let queued = {
1958            let mut inner = self.doc.lock();
1959            let dto = format.to_set_dto(pos, anchor);
1960            document_formatting_commands::set_block_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1961            let start = pos.min(anchor);
1962            let length = pos.max(anchor) - start;
1963            inner.modified = true;
1964            inner.queue_event(DocumentEvent::FormatChanged {
1965                position: start,
1966                length,
1967                kind: crate::flow::FormatChangeKind::Block,
1968            });
1969            self.queue_undo_redo_event(&mut inner)
1970        };
1971        crate::inner::dispatch_queued_events(queued);
1972        Ok(())
1973    }
1974
1975    /// Set the frame format.
1976    pub fn set_frame_format(&self, frame_id: usize, format: &FrameFormat) -> Result<()> {
1977        let (pos, anchor) = self.read_cursor();
1978        let queued = {
1979            let mut inner = self.doc.lock();
1980            let dto = format.to_set_dto(pos, anchor, frame_id);
1981            document_formatting_commands::set_frame_format(&inner.ctx, Some(inner.stack_id), &dto)?;
1982            let start = pos.min(anchor);
1983            let length = pos.max(anchor) - start;
1984            inner.modified = true;
1985            inner.queue_event(DocumentEvent::FormatChanged {
1986                position: start,
1987                length,
1988                kind: crate::flow::FormatChangeKind::Block,
1989            });
1990            self.queue_undo_redo_event(&mut inner)
1991        };
1992        crate::inner::dispatch_queued_events(queued);
1993        Ok(())
1994    }
1995
1996    // ── Edit blocks (composite undo) ─────────────────────────
1997
1998    /// Begin a group of operations that will be undone as a single unit.
1999    pub fn begin_edit_block(&self) {
2000        let inner = self.doc.lock();
2001        undo_redo_commands::begin_composite(&inner.ctx, Some(inner.stack_id));
2002    }
2003
2004    /// End the current edit block.
2005    pub fn end_edit_block(&self) {
2006        let inner = self.doc.lock();
2007        undo_redo_commands::end_composite(&inner.ctx);
2008    }
2009
2010    /// Alias for [`begin_edit_block`](Self::begin_edit_block).
2011    ///
2012    /// Semantically indicates that the new composite should be merged with
2013    /// the previous one (e.g., consecutive keystrokes grouped into a single
2014    /// undo unit). The current backend treats this identically to
2015    /// `begin_edit_block`; future versions may implement automatic merging.
2016    pub fn join_previous_edit_block(&self) {
2017        self.begin_edit_block();
2018    }
2019
2020    // ── Private helpers ─────────────────────────────────────
2021
2022    /// Queue an `UndoRedoChanged` event and return all queued events for dispatch.
2023    fn queue_undo_redo_event(&self, inner: &mut TextDocumentInner) -> QueuedEvents {
2024        let can_undo = undo_redo_commands::can_undo(&inner.ctx, Some(inner.stack_id));
2025        let can_redo = undo_redo_commands::can_redo(&inner.ctx, Some(inner.stack_id));
2026        inner.queue_event(DocumentEvent::UndoRedoChanged { can_undo, can_redo });
2027        inner.take_queued_events()
2028    }
2029
2030    fn do_delete(&self, pos: usize, anchor: usize) -> Result<()> {
2031        let queued = {
2032            let mut inner = self.doc.lock();
2033            let dto = frontend::document_editing::DeleteTextDto {
2034                position: to_i64(pos),
2035                anchor: to_i64(anchor),
2036            };
2037            let result =
2038                document_editing_commands::delete_text(&inner.ctx, Some(inner.stack_id), &dto)?;
2039            let edit_pos = pos.min(anchor);
2040            let removed = pos.max(anchor) - edit_pos;
2041            let new_pos = to_usize(result.new_position);
2042            inner.adjust_cursors(edit_pos, removed, 0);
2043            {
2044                let mut d = self.data.lock();
2045                d.position = new_pos;
2046                d.anchor = new_pos;
2047            }
2048            inner.modified = true;
2049            inner.invalidate_text_cache();
2050            inner.rehighlight_affected(edit_pos);
2051            inner.queue_event(DocumentEvent::ContentsChanged {
2052                position: edit_pos,
2053                chars_removed: removed,
2054                chars_added: 0,
2055                blocks_affected: 1,
2056            });
2057            inner.check_block_count_changed();
2058            inner.check_flow_changed();
2059            self.queue_undo_redo_event(&mut inner)
2060        };
2061        crate::inner::dispatch_queued_events(queued);
2062        Ok(())
2063    }
2064
2065    /// Resolve a MoveOperation to a concrete position.
2066    fn resolve_move(&self, op: MoveOperation, n: usize) -> usize {
2067        let pos = self.position();
2068        match op {
2069            MoveOperation::NoMove => pos,
2070            MoveOperation::Start => 0,
2071            MoveOperation::End => {
2072                let inner = self.doc.lock();
2073                document_inspection_commands::get_document_stats(&inner.ctx)
2074                    .map(|s| max_cursor_position(&s))
2075                    .unwrap_or(pos)
2076            }
2077            MoveOperation::NextCharacter | MoveOperation::Right => {
2078                let mut cur = pos;
2079                for _ in 0..n {
2080                    let next = self.next_grapheme_boundary(cur);
2081                    if next == cur {
2082                        break;
2083                    }
2084                    cur = next;
2085                }
2086                cur
2087            }
2088            MoveOperation::PreviousCharacter | MoveOperation::Left => {
2089                let mut cur = pos;
2090                for _ in 0..n {
2091                    let prev = self.prev_grapheme_boundary(cur);
2092                    if prev == cur {
2093                        break;
2094                    }
2095                    cur = prev;
2096                }
2097                cur
2098            }
2099            MoveOperation::StartOfBlock | MoveOperation::StartOfLine => {
2100                let inner = self.doc.lock();
2101                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2102                    position: to_i64(pos),
2103                };
2104                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2105                    .map(|info| to_usize(info.block_start))
2106                    .unwrap_or(pos)
2107            }
2108            MoveOperation::EndOfBlock | MoveOperation::EndOfLine => {
2109                let inner = self.doc.lock();
2110                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2111                    position: to_i64(pos),
2112                };
2113                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2114                    .map(|info| to_usize(info.block_start) + to_usize(info.block_length))
2115                    .unwrap_or(pos)
2116            }
2117            MoveOperation::NextBlock => {
2118                let inner = self.doc.lock();
2119                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2120                    position: to_i64(pos),
2121                };
2122                document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2123                    .map(|info| {
2124                        // Move past current block + 1 (block separator)
2125                        to_usize(info.block_start) + to_usize(info.block_length) + 1
2126                    })
2127                    .unwrap_or(pos)
2128            }
2129            MoveOperation::PreviousBlock => {
2130                let inner = self.doc.lock();
2131                let dto = frontend::document_inspection::GetBlockAtPositionDto {
2132                    position: to_i64(pos),
2133                };
2134                let block_start =
2135                    document_inspection_commands::get_block_at_position(&inner.ctx, &dto)
2136                        .map(|info| to_usize(info.block_start))
2137                        .unwrap_or(pos);
2138                if block_start >= 2 {
2139                    // Skip past the block separator (which maps to the current block)
2140                    let prev_dto = frontend::document_inspection::GetBlockAtPositionDto {
2141                        position: to_i64(block_start - 2),
2142                    };
2143                    document_inspection_commands::get_block_at_position(&inner.ctx, &prev_dto)
2144                        .map(|info| to_usize(info.block_start))
2145                        .unwrap_or(0)
2146                } else {
2147                    0
2148                }
2149            }
2150            MoveOperation::NextWord | MoveOperation::EndOfWord | MoveOperation::WordRight => {
2151                let (_, end) = self.find_word_boundaries(pos);
2152                // Move past the word end to the next word
2153                if end == pos {
2154                    // Already at a boundary, skip whitespace
2155                    let inner = self.doc.lock();
2156                    let max_pos = document_inspection_commands::get_document_stats(&inner.ctx)
2157                        .map(|s| max_cursor_position(&s))
2158                        .unwrap_or(0);
2159                    let scan_len = max_pos.saturating_sub(pos).min(64);
2160                    if scan_len == 0 {
2161                        return pos;
2162                    }
2163                    let dto = frontend::document_inspection::GetTextAtPositionDto {
2164                        position: to_i64(pos),
2165                        length: to_i64(scan_len),
2166                    };
2167                    if let Ok(r) =
2168                        document_inspection_commands::get_text_at_position(&inner.ctx, &dto)
2169                    {
2170                        for (i, ch) in r.text.chars().enumerate() {
2171                            if ch.is_alphanumeric() || ch == '_' {
2172                                // Found start of next word, find its end
2173                                let word_pos = pos + i;
2174                                drop(inner);
2175                                let (_, word_end) = self.find_word_boundaries(word_pos);
2176                                return word_end;
2177                            }
2178                        }
2179                    }
2180                    pos + scan_len
2181                } else {
2182                    end
2183                }
2184            }
2185            MoveOperation::PreviousWord | MoveOperation::StartOfWord | MoveOperation::WordLeft => {
2186                let (start, _) = self.find_word_boundaries(pos);
2187                if start < pos {
2188                    start
2189                } else if pos > 0 {
2190                    // Cursor is at a word start or on whitespace — scan backwards
2191                    // to find the start of the previous word.
2192                    let mut search = pos - 1;
2193                    loop {
2194                        let (ws, we) = self.find_word_boundaries(search);
2195                        if ws < we {
2196                            // Found a word; return its start
2197                            break ws;
2198                        }
2199                        // Still on whitespace/non-word; keep scanning
2200                        if search == 0 {
2201                            break 0;
2202                        }
2203                        search -= 1;
2204                    }
2205                } else {
2206                    0
2207                }
2208            }
2209            MoveOperation::Up | MoveOperation::Down => {
2210                // Up/Down are visual operations that depend on line wrapping.
2211                // Without layout info, treat as PreviousBlock/NextBlock.
2212                if matches!(op, MoveOperation::Up) {
2213                    self.resolve_move(MoveOperation::PreviousBlock, 1)
2214                } else {
2215                    self.resolve_move(MoveOperation::NextBlock, 1)
2216                }
2217            }
2218        }
2219    }
2220
2221    /// Snap the cursor's current position to the nearest grapheme
2222    /// cluster boundary, moving forward if currently mid-cluster.
2223    /// No-op when already at a boundary.
2224    ///
2225    /// Applied automatically by `cursor_at` and `set_position` so a
2226    /// caller passing an arbitrary scalar index never lands inside a
2227    /// cluster — without this, a round-trip such as
2228    /// `NextCharacter → PreviousCharacter` would stop at the cluster
2229    /// start rather than the start position, because the pre-advance
2230    /// state wasn't a boundary to begin with.
2231    pub(crate) fn snap_position_to_grapheme_boundary(&self) {
2232        let pos = {
2233            let data = self.data.lock();
2234            data.position
2235        };
2236        let snapped = self.forward_grapheme_boundary_at_or_after(pos);
2237        if snapped != pos {
2238            let mut data = self.data.lock();
2239            data.position = snapped;
2240            if data.anchor == pos {
2241                data.anchor = snapped;
2242            }
2243        }
2244    }
2245
2246    /// Return `pos` if it sits at a grapheme cluster boundary within
2247    /// its block; otherwise return the end position of the containing
2248    /// cluster (snap forward). Block separators are always treated as
2249    /// boundaries.
2250    ///
2251    /// Leaves out-of-range positions (`pos > max_cursor_position`)
2252    /// unchanged — the snap must never silently upgrade an out-of-
2253    /// range cursor to a valid one, because edit ops rely on the
2254    /// out-of-range check to stay no-ops.
2255    fn forward_grapheme_boundary_at_or_after(&self, pos: usize) -> usize {
2256        let inner = self.doc.lock();
2257        let end = document_inspection_commands::get_document_stats(&inner.ctx)
2258            .map(|s| max_cursor_position(&s))
2259            .unwrap_or(pos);
2260        if pos >= end {
2261            return pos;
2262        }
2263        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2264            position: to_i64(pos),
2265        };
2266        let Ok(block_info) =
2267            document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto)
2268        else {
2269            return pos;
2270        };
2271        let block_start = to_usize(block_info.block_start);
2272        let block_length = to_usize(block_info.block_length);
2273        let offset_in_block = pos.saturating_sub(block_start);
2274        // Block boundaries (start / end) are always cluster boundaries.
2275        if offset_in_block == 0 || offset_in_block >= block_length {
2276            return pos;
2277        }
2278        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
2279            position: to_i64(block_start),
2280            length: to_i64(block_length),
2281        };
2282        let Ok(r) = document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto)
2283        else {
2284            return pos;
2285        };
2286        let text = r.text;
2287        drop(inner);
2288        // Walk grapheme clusters, accumulating char counts. The first
2289        // boundary >= offset_in_block is the snap target.
2290        let mut acc = 0usize;
2291        for g in text.graphemes(true) {
2292            if acc >= offset_in_block {
2293                return block_start + acc;
2294            }
2295            acc += g.chars().count();
2296        }
2297        block_start + acc
2298    }
2299
2300    /// Return the cursor position after advancing one extended grapheme
2301    /// cluster from `pos`. A grapheme cluster is what a user perceives
2302    /// as a single character — decomposed accents (`e` + `U+0301`),
2303    /// skin-tone emoji, ZWJ sequences, and regional-indicator flags
2304    /// are all single clusters even though they contain multiple
2305    /// Unicode scalars.
2306    ///
2307    /// Block separators (the single scalar between blocks in the
2308    /// cursor-position space) are treated as their own unit: advancing
2309    /// from the end of a block goes to the start of the next block
2310    /// (one scalar forward) without touching the grapheme path.
2311    /// Returns `pos` unchanged when already at the document end.
2312    fn next_grapheme_boundary(&self, pos: usize) -> usize {
2313        let inner = self.doc.lock();
2314        let end = document_inspection_commands::get_document_stats(&inner.ctx)
2315            .map(|s| max_cursor_position(&s))
2316            .unwrap_or(pos);
2317        if pos >= end {
2318            return pos;
2319        }
2320        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2321            position: to_i64(pos),
2322        };
2323        let block_info =
2324            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
2325                Ok(info) => info,
2326                Err(_) => return pos + 1,
2327            };
2328        let block_start = to_usize(block_info.block_start);
2329        let block_length = to_usize(block_info.block_length);
2330        let offset_in_block = pos.saturating_sub(block_start);
2331        if offset_in_block >= block_length {
2332            // At block end — advance across the separator into the
2333            // next block.
2334            return (pos + 1).min(end);
2335        }
2336        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
2337            position: to_i64(pos),
2338            length: to_i64(block_length - offset_in_block),
2339        };
2340        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto) {
2341            Ok(r) => r.text,
2342            Err(_) => return pos + 1,
2343        };
2344        drop(inner);
2345        match text.graphemes(true).next() {
2346            Some(g) if !g.is_empty() => (pos + g.chars().count()).min(end),
2347            _ => (pos + 1).min(end),
2348        }
2349    }
2350
2351    /// Return the cursor position before the extended grapheme cluster
2352    /// that ends at `pos`. Counterpart to [`Self::next_grapheme_boundary`].
2353    /// Crosses block separators one scalar at a time.
2354    fn prev_grapheme_boundary(&self, pos: usize) -> usize {
2355        if pos == 0 {
2356            return 0;
2357        }
2358        let inner = self.doc.lock();
2359        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2360            position: to_i64(pos.saturating_sub(1)),
2361        };
2362        let block_info =
2363            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
2364                Ok(info) => info,
2365                Err(_) => return pos - 1,
2366            };
2367        let block_start = to_usize(block_info.block_start);
2368        let block_length = to_usize(block_info.block_length);
2369        let block_end = block_start + block_length;
2370        // If `pos` sits past the block text (on a separator), step back
2371        // one scalar rather than running grapheme analysis across a
2372        // boundary.
2373        if pos > block_end {
2374            return pos - 1;
2375        }
2376        if block_length == 0 || pos <= block_start {
2377            return pos.saturating_sub(1);
2378        }
2379        let scan_len = pos - block_start;
2380        let text_dto = frontend::document_inspection::GetTextAtPositionDto {
2381            position: to_i64(block_start),
2382            length: to_i64(scan_len),
2383        };
2384        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &text_dto) {
2385            Ok(r) => r.text,
2386            Err(_) => return pos - 1,
2387        };
2388        drop(inner);
2389        match text.graphemes(true).next_back() {
2390            Some(g) if !g.is_empty() => pos - g.chars().count(),
2391            _ => pos - 1,
2392        }
2393    }
2394
2395    /// Find the word boundaries around `pos`. Returns (start, end).
2396    /// Uses Unicode word segmentation for correct handling of non-ASCII text.
2397    ///
2398    /// Single-pass: tracks the last word seen to avoid a second iteration
2399    /// when the cursor is at the end of the last word (ISSUE-18).
2400    fn find_word_boundaries(&self, pos: usize) -> (usize, usize) {
2401        let inner = self.doc.lock();
2402        // Get block info so we can fetch the full block text
2403        let block_dto = frontend::document_inspection::GetBlockAtPositionDto {
2404            position: to_i64(pos),
2405        };
2406        let block_info =
2407            match document_inspection_commands::get_block_at_position(&inner.ctx, &block_dto) {
2408                Ok(info) => info,
2409                Err(_) => return (pos, pos),
2410            };
2411
2412        let block_start = to_usize(block_info.block_start);
2413        let block_length = to_usize(block_info.block_length);
2414        if block_length == 0 {
2415            return (pos, pos);
2416        }
2417
2418        let dto = frontend::document_inspection::GetTextAtPositionDto {
2419            position: to_i64(block_start),
2420            length: to_i64(block_length),
2421        };
2422        let text = match document_inspection_commands::get_text_at_position(&inner.ctx, &dto) {
2423            Ok(r) => r.text,
2424            Err(_) => return (pos, pos),
2425        };
2426
2427        // cursor_offset is the char offset within the block text
2428        let cursor_offset = pos.saturating_sub(block_start);
2429
2430        // Single pass: track the last word seen for end-of-last-word check
2431        let mut last_char_start = 0;
2432        let mut last_char_end = 0;
2433
2434        for (word_byte_start, word) in text.unicode_word_indices() {
2435            // Convert byte offset to char offset
2436            let word_char_start = text[..word_byte_start].chars().count();
2437            let word_char_len = word.chars().count();
2438            let word_char_end = word_char_start + word_char_len;
2439
2440            last_char_start = word_char_start;
2441            last_char_end = word_char_end;
2442
2443            if cursor_offset >= word_char_start && cursor_offset < word_char_end {
2444                return (block_start + word_char_start, block_start + word_char_end);
2445            }
2446        }
2447
2448        // Check if cursor is exactly at the end of the last word
2449        if cursor_offset == last_char_end && last_char_start < last_char_end {
2450            return (block_start + last_char_start, block_start + last_char_end);
2451        }
2452
2453        (pos, pos)
2454    }
2455}