Skip to main content

text_document/
cursor.rs

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